19 Commits

Author SHA1 Message Date
Javanaut
fea8ea4b70 Release v0.3.1 2026-04-16 19:44:07 +02:00
Javanaut
1bead05d19 ff 2026-04-16 19:36:40 +02:00
Javanaut
9fe2a842e9 ff 2026-04-16 19:32:41 +02:00
Javanaut
849d03d054 v0.3.1 2026-04-16 19:26:17 +02:00
Javanaut
3a87bbbba6 Anpassung --cut flag 2026-04-16 19:02:57 +02:00
Javanaut
ab5e8e53e1 Fix debug title 2026-04-16 18:32:07 +02:00
Javanaut
0ab2408444 Fix h265 format 2026-04-16 18:20:17 +02:00
Javanaut
bc1e0889e7 Fix inspect details screen 2026-04-16 18:10:39 +02:00
Javanaut
6dfbe1022a Merge branch 'dev' of gitea.maveno.de:Javanaut/ffx into dev 2026-04-15 15:50:27 +02:00
Javanaut
d3d2de8a0d adds scratchpad points 2026-04-15 15:50:24 +02:00
Javanaut
0728ece4b8 Fix h265 subtrack unmux 2026-04-15 00:03:17 +02:00
Javanaut
02e375fbf2 nnn 2026-04-14 19:08:29 +02:00
Javanaut
14e6ce8458 Fix logging 2026-04-14 10:04:39 +02:00
Javanaut
d314b6024d Release v0.3.0 2026-04-14 00:56:21 +02:00
Javanaut
d921629947 v0.3.0 2026-04-14 00:55:42 +02:00
Javanaut
65490e2a7f ff 2026-04-14 00:44:43 +02:00
Javanaut
6c5b518e4d ffn 2026-04-14 00:26:16 +02:00
Javanaut
e3c18f22d4 Adds UI tweaks nightly 2026-04-13 23:11:14 +02:00
Javanaut
57185c7f10 Adds missing codecs 2026-04-13 20:15:10 +02:00
41 changed files with 1834 additions and 136 deletions

1
.gitignore vendored
View File

@@ -20,5 +20,6 @@ venv/
*.mkv
*.webm
*.mp4
ffmpeg2pass-0.log
*.sup

View File

@@ -99,6 +99,20 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ
## Version History
### 0.3.1
- debug mode screen titles now append the active Textual screen class name, making screen-specific troubleshooting easier during inspect and edit flows
- `--cut` again works as a combined flag/option: omitted disables cutting, bare `--cut` applies the default `60,180`, and explicit duration or `START,DURATION` values stay supported
- H.265 unmux commands no longer force an invalid `-f h265` output format, keeping ffmpeg copy extraction aligned with the required Annex B bitstream filter
- H.264 encoding now falls back from `libx264` to `libopenh264` with a warning when needed, and the test fixtures use the same encoder fallback so the suite remains portable across ffmpeg builds
### 0.3.0
- inspect and edit screens now refresh nested track and pattern changes more reliably, with inspect-mode tables aligned to the target pattern view shown in the differences pane
- metadata editing got a follow-up polish pass with clearer ffmpeg notifications, a shared in-screen log pane, safer apply/reload handling, and expanded cleanup and normalization coverage
- track and asset probing recognize additional codecs, and the modern test suite now covers more metadata-editor, change-set, screen-state, and asset-probe behavior
- Textual now requires version `8.0` or newer to match the UI APIs used by the current screens
### 0.2.6
- DB-free `ffx edit` workflow for in-place metadata editing via temporary-file rewrite

View File

@@ -1,13 +1,13 @@
[project]
name = "ffx"
description = "FFX recoding and metadata managing tool"
version = "0.2.6"
version = "0.3.1"
license = {file = "LICENSE.md"}
dependencies = [
"requests",
"jinja2",
"click",
"textual",
"textual>=8.0",
"sqlalchemy",
]
readme = {file = "README.md", content-type = "text/markdown"}

View File

@@ -252,9 +252,15 @@ def buildRenameTargetFilename(
@click.pass_context
@click.option('--language', 'app_language', type=str, default='', help='Set application language')
@click.option('--database-file', type=str, default='', help='Path to database file')
@click.option(
'--debug',
is_flag=True,
default=False,
help='Enable debug-only TUI diagnostics such as the log pane',
)
@click.option('-v', '--verbose', type=int, default=0, help='Set verbosity of output')
@click.option("--dry-run", is_flag=True, default=False)
def ffx(ctx, app_language, database_file, verbose, dry_run):
def ffx(ctx, app_language, database_file, debug, verbose, dry_run):
"""FFX"""
ctx.obj = {}
@@ -274,6 +280,7 @@ def ffx(ctx, app_language, database_file, verbose, dry_run):
)
set_current_language(resolvedLanguage)
ctx.obj['language'] = resolvedLanguage
ctx.obj['debug'] = bool(debug)
if ctx.invoked_subcommand in LIGHTWEIGHT_COMMANDS:
ctx.obj['dry_run'] = dry_run
@@ -287,6 +294,7 @@ def ffx(ctx, app_language, database_file, verbose, dry_run):
ctx.obj['dry_run'] = dry_run
ctx.obj['verbosity'] = verbose
ctx.obj['debug'] = bool(debug)
ctx.obj['language'] = resolve_application_language(
cli_language=app_language,
config_language=ctx.obj['config'].getLanguage(),
@@ -391,6 +399,20 @@ def runScriptWrapper(ctx, scriptPath, missingDescription, commandArgs):
ctx.exit(completed.returncode)
def runTuiApp(ctx) -> None:
from ffx.ffx_app import FfxApp
from ffx.logging_utils import set_ffx_console_logging_enabled
logger = ctx.obj.get('logger')
set_ffx_console_logging_enabled(logger, enabled=False)
try:
app = FfxApp(ctx.obj)
app.run()
finally:
set_ffx_console_logging_enabled(logger, enabled=True)
@ffx.command(name='setup')
@click.pass_context
@click.option('--check', is_flag=True, default=False, help='Only verify bundle-setup readiness')
@@ -527,14 +549,11 @@ def inspect(ctx, shift, filenames):
if len(filenames) != 1:
raise click.ClickException("Inspect without --shift requires exactly one filename.")
from ffx.ffx_app import FfxApp
ctx.obj['command'] = 'inspect'
ctx.obj['arguments'] = {}
ctx.obj['arguments']['filename'] = filenames[0]
app = FfxApp(ctx.obj)
app.run()
runTuiApp(ctx)
@ffx.command()
@@ -544,8 +563,6 @@ def edit(ctx, 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
@@ -554,8 +571,7 @@ def edit(ctx, filename):
ctx.obj['apply_metadata_normalization'] = True
ctx.obj['resource_limits'] = ctx.obj.get('resource_limits', {})
app = FfxApp(ctx.obj)
app.run()
runTuiApp(ctx)
@ffx.command()
@@ -615,21 +631,24 @@ def rename(ctx, paths, prefix, season, suffix, dry_run):
def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix, targetDirectory = ''):
from ffx.track_codec import TrackCodec
from ffx.track_type import TrackType
# executable and input file
commandTokens = list(FFMPEG_COMMAND_TOKENS) + ['-i', sourcePath]
trackType = trackDescriptor.getType()
trackCodec = trackDescriptor.getCodec()
targetPathBase = os.path.join(targetDirectory, targetPrefix) if targetDirectory else targetPrefix
# mapping
commandTokens += ['-map',
f"0:{trackType.indicator()}:{trackDescriptor.getSubIndex()}",
'-c',
'copy']
commandTokens += ['-map', f"0:{trackType.indicator()}:{trackDescriptor.getSubIndex()}"]
trackCodec = trackDescriptor.getCodec()
if trackType == TrackType.VIDEO and trackCodec == TrackCodec.H265:
commandTokens += ['-c:v', 'copy', '-bsf:v', 'hevc_mp4toannexb']
else:
commandTokens += ['-c', 'copy']
# output format
codecFormat = trackCodec.format()
@@ -837,12 +856,8 @@ def cropdetect(ctx,
@click.pass_context
def shows(ctx):
from ffx.ffx_app import FfxApp
ctx.obj['command'] = 'shows'
app = FfxApp(ctx.obj)
app.run()
runTuiApp(ctx)
def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
@@ -943,7 +958,6 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
metavar="DURATION|START,DURATION",
is_flag=False,
flag_value=DEFAULT_CUT_OPTION_VALUE,
default=None,
callback=normalizeCutOption,
help=CUT_OPTION_HELP,
)

View File

@@ -3,6 +3,7 @@ from textual.screen import Screen
from textual.widgets import Button, Footer, Header, Static
from .i18n import t
from .screen_support import build_screen_log_pane
class ConfirmScreen(Screen):
@@ -58,8 +59,16 @@ class ConfirmScreen(Screen):
yield Button(self.__confirmLabel, id="confirm_button")
yield Button(self.__cancelLabel, id="cancel_button")
yield build_screen_log_pane()
yield Footer()
def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "confirm_button":
self.dismiss(True)

View File

@@ -1,4 +1,4 @@
VERSION='0.2.6'
VERSION='0.3.1'
DATABASE_VERSION = 3
DEFAULT_QUALITY = 32

View File

@@ -4,6 +4,7 @@ from .i18n import set_current_language, t
from .shows_screen import ShowsScreen
from .inspect_details_screen import InspectDetailsScreen
from .media_edit_screen import MediaEditScreen
from .screen_support import configure_screen_log_handler, set_screen_log_pane_enabled
class FfxApp(App):
@@ -22,6 +23,13 @@ class FfxApp(App):
# Data 'input' variable
self.context = context
set_current_language(self.context.get("language"))
debug_mode = bool(self.context.get("debug", False))
set_screen_log_pane_enabled(debug_mode)
configure_screen_log_handler(
self.context.get("logger"),
self,
enabled=debug_mode,
)
def on_mount(self) -> None:

View File

@@ -1,4 +1,5 @@
import os, click
import os, click, subprocess
from functools import lru_cache
from logging import Logger
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
@@ -61,6 +62,41 @@ class FfxController():
sourceMediaDescriptor)
self.__logger: Logger = context['logger']
self.__warnedH264Fallback = False
@staticmethod
@lru_cache(maxsize=None)
def isFfmpegEncoderAvailable(encoderName: str) -> bool:
completed = subprocess.run(
["ffmpeg", "-encoders"],
capture_output=True,
text=True,
check=False,
)
if completed.returncode != 0:
return False
resolvedEncoderName = str(encoderName).strip()
for line in completed.stdout.splitlines():
if not line.startswith(" "):
continue
tokens = line.split(maxsplit=2)
if len(tokens) >= 2 and tokens[1] == resolvedEncoderName:
return True
return False
@classmethod
def getSupportedSoftwareH264Encoder(cls) -> str | None:
if cls.isFfmpegEncoderAvailable("libx264"):
return "libx264"
if cls.isFfmpegEncoderAvailable("libopenh264"):
return "libopenh264"
return None
def executeCommandSequence(self, commandSequence):
@@ -79,10 +115,27 @@ class FfxController():
# -c:v libx264 -preset slow -crf 17
def generateH264Tokens(self, quality, subIndex : int = 0):
h264Encoder = self.getSupportedSoftwareH264Encoder()
return [f"-c:v:{int(subIndex)}", 'libx264',
"-preset", "slow",
'-crf', str(quality)]
if h264Encoder == "libx264":
return [f"-c:v:{int(subIndex)}", 'libx264',
"-preset", "slow",
'-crf', str(quality)]
if h264Encoder == "libopenh264":
if not self.__warnedH264Fallback:
self.__logger.warning(
"libx264 encoder unavailable; falling back to libopenh264 for H.264 encoding."
)
self.__warnedH264Fallback = True
return [f"-c:v:{int(subIndex)}", 'libopenh264',
'-pix_fmt', 'yuv420p']
raise click.ClickException(
"H.264 encoding requested but no supported software H.264 encoder is available. "
+ "Tried libx264 and libopenh264."
)
# -c:v:0 libvpx-vp9 -row-mt 1 -crf 32 -pass 1 -speed 4 -frame-parallel 0 -g 9999 -aq-mode 0

View File

@@ -3,7 +3,7 @@ from textual.screen import Screen
from textual.widgets import Footer, Placeholder
from .i18n import t
from .screen_support import go_back_or_exit
from .screen_support import build_screen_log_pane, go_back_or_exit
class HelpScreen(Screen):
BINDINGS = [
@@ -17,7 +17,15 @@ class HelpScreen(Screen):
def compose(self) -> ComposeResult:
# Row 1
yield Placeholder(t("Help Screen"))
yield build_screen_log_pane()
yield Footer()
def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
def action_back(self):
go_back_or_exit(self)

View File

@@ -6,12 +6,23 @@ from .configuration_controller import ConfigurationController
from .logging_utils import get_ffx_logger
from .show_descriptor import ShowDescriptor
from enum import Enum
class EmptyStringUndefined(Undefined):
def __str__(self):
return ''
class LogLevel(Enum):
DEBUG = 'debug'
INFO = 'info'
WARNING = 'warning'
ERROR = 'error'
CRITICAL = 'critical'
DIFF_ADDED_KEY = 'added'
DIFF_REMOVED_KEY = 'removed'
DIFF_CHANGED_KEY = 'changed'
@@ -119,7 +130,7 @@ def setDiff(a : set, b : set) -> set:
def permutateList(inputList: list, permutation: list):
# 0,1,2: ABC
# 0,2,1: ACB
# 0,2,1: ACBffmpeg:
# 1,2,0: BCA
pass

View File

@@ -8,6 +8,7 @@ from textual.widgets import Button, Footer, Header, Input, Static
from textual.widgets._data_table import CellDoesNotExist
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.show_descriptor import ShowDescriptor
from ffx.track_descriptor import TrackDescriptor
@@ -18,6 +19,7 @@ from .pattern_details_screen import PatternDetailsScreen
from .screen_support import (
add_auto_table_column,
build_screen_controllers,
build_screen_log_pane,
go_back_or_exit,
localized_column_width,
update_table_column_label,
@@ -37,8 +39,8 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
CSS = f"""
Grid {{
grid-size: 6 11;
grid-rows: 9 2 2 2 2 8 2 2 2 8 8;
grid-size: 6 8;
grid-rows: 9 2 2 2 2 10 2 10;
grid-columns: {GRID_COLUMN_LABEL_MIN} {GRID_COLUMN_2} {GRID_COLUMN_3} {GRID_COLUMN_4} {GRID_COLUMN_5} {GRID_COLUMN_6};
height: 100%;
width: 100%;
@@ -86,6 +88,10 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
#differences-table {{
row-span: 10;
}}
.yellow {{
tint: yellow 40%;
}}
"""
@classmethod
@@ -155,6 +161,7 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
yield Static(" ")
yield self.differencesTable
# Row 2
yield Static(" ", classes="five")
@@ -163,33 +170,31 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
yield Button(t("Substitute"), id="pattern_button")
yield Static(" ", classes="three")
# Row 4
yield Static(t("Pattern"))
yield Input(type="text", id="pattern_input", classes="three")
yield Static(" ")
# Row 5
yield Static(" ", classes="five")
# Row 6
yield Static(t("Media Tags"))
yield self.mediaTagsTable
yield Static(" ", classes="two")
yield Static(" ")
# Row 7
yield Static(" ", classes="five")
# Row 8
yield Static(" ")
yield Button(t("Set Default"), id="select_default_button")
yield Button(t("Set Forced"), id="select_forced_button")
yield Static(" ", classes="two")
# Row 9
yield Static(t("Streams"))
yield self.tracksTable
yield Static(" ")
yield build_screen_log_pane()
yield Footer()
def _update_grid_layout(self) -> None:
@@ -205,6 +210,30 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
def action_back(self):
go_back_or_exit(self)
def getDisplayedMediaDescriptor(self):
if self._currentPattern is not None and self._targetMediaDescriptor is not None:
return self._targetMediaDescriptor
return self._sourceMediaDescriptor
def getTrackEditSourceDescriptor(self):
selectedTrackDescriptor = self.getSelectedTrackDescriptor()
if (
selectedTrackDescriptor is None
or self._currentPattern is None
or self._targetMediaDescriptor is None
):
return selectedTrackDescriptor
for sourceTrackDescriptor in self._sourceMediaDescriptor.getTrackDescriptors():
if (
sourceTrackDescriptor.getSourceIndex()
== selectedTrackDescriptor.getSourceIndex()
and sourceTrackDescriptor.getType() == selectedTrackDescriptor.getType()
):
return sourceTrackDescriptor
return None
def _build_shows_table(self):
from textual.widgets import DataTable
@@ -287,6 +316,10 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
self._update_show_header_labels()
def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
self._update_grid_layout()
if self._currentPattern is None:
@@ -476,8 +509,6 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
self.updateDifferences()
return updated
self.reloadProperties(reset_draft=True)
tagDifferences = self._mediaChangeSetObj.get(MediaDescriptorChangeSet.TAGS_KEY, {})
for addedTagKey in tagDifferences.get(DIFF_ADDED_KEY, {}).keys():
self._tac.deleteMediaTagByKey(self._currentPattern.getId(), addedTagKey)
@@ -564,9 +595,6 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
)
def handle_edit_pattern(self, screenResult):
if not screenResult:
return
self.reloadProperties(reset_draft=True)
if self._currentPattern is not None:
self.query_one("#pattern_input", Input).value = self._currentPattern.getPattern()

View File

@@ -5,6 +5,7 @@ import os
FFX_LOGGER_NAME = "FFX"
CONSOLE_HANDLER_NAME = "ffx-console"
FILE_HANDLER_NAME = "ffx-file"
MUTED_CONSOLE_LEVEL = logging.CRITICAL + 1
def get_ffx_logger(name: str = FFX_LOGGER_NAME) -> logging.Logger:
@@ -66,3 +67,31 @@ def configure_ffx_logger(
)
return logger
def set_ffx_console_logging_enabled(
logger: logging.Logger | None,
*,
enabled: bool,
):
if logger is None:
return None
console_handler = next(
(handler for handler in logger.handlers if handler.get_name() == CONSOLE_HANDLER_NAME),
None,
)
if console_handler is None:
return None
if enabled:
saved_level = getattr(console_handler, "_ffx_saved_level", None)
if saved_level is not None:
console_handler.setLevel(saved_level)
delattr(console_handler, "_ffx_saved_level")
return console_handler
if not hasattr(console_handler, "_ffx_saved_level"):
console_handler._ffx_saved_level = console_handler.level
console_handler.setLevel(MUTED_CONSOLE_LEVEL)
return console_handler

View File

@@ -203,7 +203,7 @@ class MediaDescriptorChangeSet():
if (
self.__applyMetadataNormalization
and trackDescriptor is not None
and trackDescriptor.getType() == TrackType.SUBTITLE
and trackDescriptor.getType() in (TrackType.VIDEO, TrackType.AUDIO, TrackType.SUBTITLE)
):
trackTitle = str(normalizedTrackTags.get("title", "")).strip()
fallbackTitle = str((fallbackTrackTags or {}).get("title", "")).strip()
@@ -260,6 +260,8 @@ class MediaDescriptorChangeSet():
# else:
# dispositionTokens += [f"-disposition:{streamIndicator}:{subIndex}", '0']
for ttd in self.__targetTrackDescriptors:
if ttd.getType() == TrackType.ATTACHMENT:
continue
targetDispositions = ttd.getDispositionSet()
streamIndicator = ttd.getType().indicator()
@@ -344,7 +346,7 @@ class MediaDescriptorChangeSet():
for tagKey, tagValue in self.normalizeTrackTags(
outputTrackTags,
trackDescriptor=trackDescriptor,
fallbackTrackTags=unchangedTrackTags | removedTrackTags,
fallbackTrackTags=trackDescriptor.getTags(),
).items():
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
+ f":{trackDescriptor.getSubIndex()}",
@@ -366,6 +368,7 @@ class MediaDescriptorChangeSet():
for tagKey, tagValue in self.normalizeTrackTags(
preservedTrackTags,
trackDescriptor=trackDescriptor,
fallbackTrackTags=trackDescriptor.getTags(),
).items():
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
+ f":{trackDescriptor.getSubIndex()}",

View File

@@ -1,6 +1,9 @@
import os
from time import monotonic
from textual import events, work
from textual.containers import Grid
from textual.worker import Worker, WorkerState
from textual.widgets import Button, Footer, Header, Static
from ffx.metadata_editor import apply_metadata_edits
@@ -9,11 +12,13 @@ from ffx.track_descriptor import TrackDescriptor
from .i18n import t
from .confirm_screen import ConfirmScreen
from .media_workflow_screen_base import MediaWorkflowScreenBase
from .screen_support import localized_column_width
from .screen_support import build_screen_log_pane, localized_column_width
from .tag_delete_screen import TagDeleteScreen
from .tag_details_screen import TagDetailsScreen
from .track_details_screen import TrackDetailsScreen
from .helper import LogLevel
class MediaEditScreen(MediaWorkflowScreenBase):
@@ -169,14 +174,27 @@ class MediaEditScreen(MediaWorkflowScreenBase):
yield Button(t("Quit"), id="quit_button")
yield Static(" ")
yield build_screen_log_pane()
yield Footer()
def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
self._update_grid_layout()
self.updateMediaTags()
self.updateTracks()
self.updateDifferences()
self.updateToggleButtons()
self._applyChangesWorker = None
def on_screen_resume(self, _event: events.ScreenResume) -> None:
if not hasattr(self, "tracksTable"):
return
self.refreshAfterDraftChange()
self.updateToggleButtons()
def _update_grid_layout(self) -> None:
leftColumnWidth = max(
@@ -195,6 +213,41 @@ class MediaEditScreen(MediaWorkflowScreenBase):
if self._messageText:
self.notify(self._messageText)
def workerLoggingHandler(self,
message: str,
level: LogLevel = LogLevel.INFO) -> None:
if level == LogLevel.DEBUG:
self.context["logger"].debug(str(message))
elif level == LogLevel.INFO:
self.context["logger"].info(str(message))
elif level == LogLevel.WARNING:
self.context["logger"].warning(str(message))
elif level == LogLevel.ERROR:
self.context["logger"].error(str(message))
elif level == LogLevel.CRITICAL:
self.context["logger"].critical(str(message))
else:
raise Exception(f"Undefined Logging Level (msg={message})")
def _report_apply_timings(self, applyResult: dict, reloadSeconds: float = 0.0) -> None:
timings = dict(applyResult.get("timings", {}))
ffmpegSeconds = float(timings.get("ffmpeg_seconds", 0.0))
replaceSeconds = float(timings.get("replace_seconds", 0.0))
writeSeconds = float(timings.get("write_seconds", ffmpegSeconds + replaceSeconds))
reloadSeconds = float(reloadSeconds)
totalSeconds = writeSeconds + reloadSeconds
timingSummary = (
f"ffx edit timings: ffmpeg={ffmpegSeconds:.2f}s "
+ f"replace={replaceSeconds:.2f}s "
+ f"reload={reloadSeconds:.2f}s "
+ f"total={totalSeconds:.2f}s"
)
self.context["logger"].info(timingSummary)
def updateToggleButtons(self):
self._set_toggle_button_state(
"#cleanup_toggle_button",
@@ -296,6 +349,7 @@ class MediaEditScreen(MediaWorkflowScreenBase):
def action_toggle_normalization(self):
self.setApplyNormalization(not self._applyNormalization)
self.updateToggleButtons()
self.updateTracks()
self.updateDifferences()
self.setMessage(
t("Normalization enabled.")
@@ -323,30 +377,35 @@ class MediaEditScreen(MediaWorkflowScreenBase):
if trackDescriptor is None:
return
updatedTracks = []
nextSourceMediaDescriptor = self._sourceMediaDescriptor.clone(context=self.context)
updatedTracks = nextSourceMediaDescriptor.getTrackDescriptors()
replacementTrack = trackDescriptor.clone(context=self.context)
replaced = False
for currentTrack in self._sourceMediaDescriptor.getTrackDescriptors():
if (
currentTrack.getIndex() == trackDescriptor.getIndex()
and currentTrack.getSubIndex() == trackDescriptor.getSubIndex()
):
updatedTracks.append(trackDescriptor)
for trackIndex, currentTrack in enumerate(updatedTracks):
sameSourceTrack = (
currentTrack.getSourceIndex() == replacementTrack.getSourceIndex()
and currentTrack.getType() == replacementTrack.getType()
)
sameVisibleTrack = (
currentTrack.getIndex() == replacementTrack.getIndex()
and currentTrack.getSubIndex() == replacementTrack.getSubIndex()
)
if sameSourceTrack or sameVisibleTrack:
updatedTracks[trackIndex] = replacementTrack
replaced = True
else:
updatedTracks.append(currentTrack)
break
if not replaced:
self.setMessage(t("Unable to update selected stream."))
return
self._sourceMediaDescriptor = self._sourceMediaDescriptor.clone(context=self.context)
self._sourceMediaDescriptor.getTrackDescriptors().clear()
self._sourceMediaDescriptor.getTrackDescriptors().extend(updatedTracks)
self._sourceMediaDescriptor = nextSourceMediaDescriptor
self.setMessage(
t(
"Updated stream #{index} ({track_type}).",
index=trackDescriptor.getIndex(),
track_type=t(trackDescriptor.getType().label()),
index=replacementTrack.getIndex(),
track_type=t(replacementTrack.getType().label()),
)
)
self.refreshAfterDraftChange()
@@ -356,33 +415,77 @@ class MediaEditScreen(MediaWorkflowScreenBase):
self.setMessage(t("No changes to apply."))
return
try:
applyResult = apply_metadata_edits(
self.context,
self._mediaFilename,
self._baselineMediaDescriptor,
self._sourceMediaDescriptor,
)
except Exception as ex:
self.context["logger"].exception(
"Failed to apply metadata edits for %s",
self._mediaFilename,
)
self.setMessage(t("Apply failed: {error}", error=ex))
if self._applyChangesWorker is not None and self._applyChangesWorker.is_running:
self.setMessage(t("Apply already running."))
return
self.context["logger"].info(
t("Starting metadata apply for {filename}.", filename=self._mediaFilename)
)
self._applyChangesWorker = self.run_apply_changes_worker()
@work(
thread=True,
exclusive=True,
group="media-edit-apply",
exit_on_error=False,
)
def run_apply_changes_worker(self):
return apply_metadata_edits(
self.context,
self._mediaFilename,
self._baselineMediaDescriptor,
self._sourceMediaDescriptor,
loggingHandler = self.workerLoggingHandler,
)
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
if event.worker is not self._applyChangesWorker:
return
if event.state == WorkerState.ERROR:
error = event.worker.error
if error is not None:
self.context["logger"].error(
"Failed to apply metadata edits for %s",
self._mediaFilename,
exc_info=(type(error), error, error.__traceback__),
)
self.setMessage(t("Apply failed: {error}", error=error))
self._applyChangesWorker = None
return
if event.state != WorkerState.SUCCESS:
return
applyResult = event.worker.result or {}
if applyResult.get("dry_run", False):
self._report_apply_timings(applyResult, reloadSeconds=0.0)
self.context["logger"].info(
t(
"Dry-run prepared temporary output {target_path}.",
target_path=applyResult["target_path"],
),
)
self.setMessage(
t(
"Dry-run: would rewrite via temporary file {target_path}",
target_path=applyResult["target_path"],
)
)
self._applyChangesWorker = None
return
reloadStart = monotonic()
self.context["logger"].info(t("Reloading file after metadata write."))
self.reloadProperties(reset_draft=True)
self.refreshAfterDraftChange()
reloadSeconds = monotonic() - reloadStart
self._report_apply_timings(applyResult, reloadSeconds=reloadSeconds)
self.context["logger"].info(t("Changes applied and file reloaded."))
self.setMessage(t("Changes applied and file reloaded."))
self._applyChangesWorker = None
def action_revert_changes(self):
if not self.hasPendingChanges():

View File

@@ -9,6 +9,8 @@ from textual.widgets._data_table import CellDoesNotExist
from ffx.audio_layout import AudioLayout
from ffx.file_properties import FileProperties
from ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY
from ffx.iso_language import IsoLanguage
from ffx.media_descriptor import MediaDescriptor
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
from ffx.track_descriptor import TrackDescriptor
from ffx.track_disposition import TrackDisposition
@@ -170,10 +172,17 @@ class MediaWorkflowScreenBase(Screen):
def hasPendingChanges(self) -> bool:
return bool(self._mediaChangeSetObj)
def getDisplayedMediaDescriptor(self) -> MediaDescriptor | None:
return self._sourceMediaDescriptor
def getTrackEditSourceDescriptor(self) -> TrackDescriptor | None:
return self.getSelectedTrackDescriptor()
def updateMediaTags(self):
displayedMediaDescriptor = self.getDisplayedMediaDescriptor()
self._sourceMediaTagRowData = populate_tag_table(
self.mediaTagsTable,
self._sourceMediaDescriptor.getTags(),
displayedMediaDescriptor.getTags() if displayedMediaDescriptor is not None else {},
ignore_keys=self._ignoreGlobalKeys,
remove_keys=self._removeGlobalKeys,
)
@@ -183,8 +192,14 @@ class MediaWorkflowScreenBase(Screen):
self._configure_tracks_table_columns()
self._trackRowData = {}
trackDescriptorList = self._sourceMediaDescriptor.getTrackDescriptors()
displayedMediaDescriptor = self.getDisplayedMediaDescriptor()
trackDescriptorList = (
displayedMediaDescriptor.getTrackDescriptors()
if displayedMediaDescriptor is not None
else []
)
typeCounter = {}
applyNormalization = bool(getattr(self, "_applyNormalization", False))
for trackDescriptor in trackDescriptorList:
trackType = trackDescriptor.getType()
@@ -193,6 +208,15 @@ class MediaWorkflowScreenBase(Screen):
dispositionSet = trackDescriptor.getDispositionSet()
audioLayout = trackDescriptor.getAudioLayout()
trackTitle = trackDescriptor.getTitle()
if (
applyNormalization
and not str(trackTitle).strip()
and trackType in (TrackType.VIDEO, TrackType.AUDIO, TrackType.SUBTITLE)
):
trackLanguage = trackDescriptor.getLanguage()
if trackLanguage != IsoLanguage.UNDEFINED:
trackTitle = trackLanguage.label()
row = (
trackDescriptor.getIndex(),
t(trackType.label()),
@@ -203,7 +227,7 @@ class MediaWorkflowScreenBase(Screen):
and audioLayout != AudioLayout.LAYOUT_UNDEFINED
else " ",
trackDescriptor.getLanguage().label(),
trackDescriptor.getTitle(),
trackTitle,
t("Yes") if TrackDisposition.DEFAULT in dispositionSet else t("No"),
t("Yes") if TrackDisposition.FORCED in dispositionSet else t("No"),
)
@@ -355,7 +379,7 @@ class MediaWorkflowScreenBase(Screen):
return None
def setSelectedTrackDefault(self):
selectedTrackDescriptor = self.getSelectedTrackDescriptor()
selectedTrackDescriptor = self.getTrackEditSourceDescriptor()
if selectedTrackDescriptor is None:
return False
@@ -366,7 +390,7 @@ class MediaWorkflowScreenBase(Screen):
return True
def setSelectedTrackForced(self):
selectedTrackDescriptor = self.getSelectedTrackDescriptor()
selectedTrackDescriptor = self.getTrackEditSourceDescriptor()
if selectedTrackDescriptor is None:
return False

View File

@@ -1,17 +1,23 @@
from __future__ import annotations
import click
import os
import tempfile
from time import monotonic
from .constants import (
DEFAULT_AC3_BANDWIDTH,
DEFAULT_DTS_BANDWIDTH,
DEFAULT_STEREO_BANDWIDTH,
FFMPEG_COMMAND_TOKENS,
)
from .ffx_controller import FfxController
from .media_descriptor import MediaDescriptor
from .media_descriptor_change_set import MediaDescriptorChangeSet
from .process import executeProcess, formatCommandSequence
from .video_encoder import VideoEncoder
from .helper import LogLevel
def create_temporary_output_path(source_path: str) -> str:
sourceDirectory = os.path.dirname(os.path.abspath(source_path)) or "."
@@ -49,41 +55,123 @@ def build_metadata_edit_context(context: dict) -> dict:
return editContext
def build_metadata_edit_command(
context: dict,
source_path: str,
target_path: str,
baseline_descriptor: MediaDescriptor,
draft_descriptor: MediaDescriptor,
) -> list[str]:
changeSet = MediaDescriptorChangeSet(context, draft_descriptor, baseline_descriptor)
return (
list(FFMPEG_COMMAND_TOKENS)
+ ["-i", source_path, "-map", "0", "-c", "copy"]
+ changeSet.generateMetadataTokens()
+ changeSet.generateDispositionTokens()
+ [target_path]
)
def notify_ffmpeg_invocation(
context: dict,
command_sequence: list[str],
*,
loggingHandler = None,
dry_run: bool = False,
) -> None:
loggingCallback = loggingHandler or context.get("logging_handler")
if not callable(loggingCallback):
return
verbosity = int(context.get("verbosity", 0) or 0)
if verbosity > 0:
if dry_run:
loggingCallback(f"ffmpeg dry-run: {formatCommandSequence(command_sequence)}", level = LogLevel.DEBUG)
else:
loggingCallback(f"ffmpeg: {formatCommandSequence(command_sequence)}", level = LogLevel.DEBUG)
return
loggingCallback("ffmpeg dry-run prepared.") if dry_run else loggingCallback(
"ffmpeg metadata write started."
)
def apply_metadata_edits(
context: dict,
source_path: str,
baseline_descriptor: MediaDescriptor,
draft_descriptor: MediaDescriptor,
*,
loggingHandler = None,
) -> dict[str, object]:
temporaryOutputPath = create_temporary_output_path(source_path)
editContext = build_metadata_edit_context(context)
controller = FfxController(editContext, draft_descriptor, baseline_descriptor)
commandSequence = build_metadata_edit_command(
editContext,
source_path,
temporaryOutputPath,
baseline_descriptor,
draft_descriptor,
)
ffmpegSeconds = 0.0
replaceSeconds = 0.0
try:
controller.runJob(
source_path,
temporaryOutputPath,
targetFormat="",
chainIteration=[],
)
if editContext.get("dry_run", False):
notify_ffmpeg_invocation(
editContext,
commandSequence,
loggingHandler = loggingHandler,
dry_run=True,
)
return {
"applied": False,
"dry_run": True,
"target_path": temporaryOutputPath,
"command_sequence": commandSequence,
"timings": {
"ffmpeg_seconds": ffmpegSeconds,
"replace_seconds": replaceSeconds,
"write_seconds": ffmpegSeconds + replaceSeconds,
},
}
notify_ffmpeg_invocation(editContext,
commandSequence,
loggingHandler = loggingHandler)
ffmpegStart = monotonic()
_out, err, rc = executeProcess(commandSequence, context=editContext)
ffmpegSeconds = monotonic() - ffmpegStart
if rc:
raise click.ClickException(f"ffmpeg edit failed: rc={rc} error={err}")
replaceStart = monotonic()
os.replace(temporaryOutputPath, source_path)
replaceSeconds = monotonic() - replaceStart
return {
"applied": True,
"dry_run": False,
"target_path": source_path,
"command_sequence": commandSequence,
"timings": {
"ffmpeg_seconds": ffmpegSeconds,
"replace_seconds": replaceSeconds,
"write_seconds": ffmpegSeconds + replaceSeconds,
},
}
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)

View File

@@ -7,7 +7,7 @@ from textual.containers import Grid
from .i18n import t
from .show_controller import ShowController
from .pattern_controller import PatternController
from .screen_support import go_back_or_exit
from .screen_support import build_screen_log_pane, go_back_or_exit
from ffx.model.pattern import Pattern
@@ -68,6 +68,10 @@ class PatternDeleteScreen(Screen):
def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
if self.__showDescriptor:
self.query_one("#showlabel", Static).update(f"{self.__showDescriptor.getId()} - {self.__showDescriptor.getName()} ({self.__showDescriptor.getYear()})")
if not self.__pattern is None:
@@ -103,6 +107,7 @@ class PatternDeleteScreen(Screen):
yield Button(t("Delete"), id="delete_button")
yield Button(t("Cancel"), id="cancel_button")
yield build_screen_log_pane()
yield Footer()

View File

@@ -1,6 +1,7 @@
import click, re
from typing import List
from textual import events
from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, Input, DataTable, TextArea
from textual.containers import Grid
@@ -18,6 +19,7 @@ from .screen_support import (
add_auto_table_column,
build_screen_bootstrap,
build_screen_controllers,
build_screen_log_pane,
go_back_or_exit,
populate_tag_table,
)
@@ -324,6 +326,9 @@ class PatternDetailsScreen(Screen):
def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
if not self.__showDescriptor is None:
self.query_one("#showlabel", Static).update(f"{self.__showDescriptor.getId()} - {self.__showDescriptor.getName()} ({self.__showDescriptor.getYear()})")
@@ -341,6 +346,16 @@ class PatternDetailsScreen(Screen):
self.updateTracks()
self.updateShiftedSeasons()
def on_screen_resume(self, _event: events.ScreenResume) -> None:
if not hasattr(self, "tracksTable") or not hasattr(self, "tagsTable"):
return
self.updateTags()
self.updateTracks()
if self.__pattern is not None and hasattr(self, "shiftedSeasonsTable"):
self.updateShiftedSeasons()
def compose(self):
@@ -429,9 +444,9 @@ class PatternDetailsScreen(Screen):
yield Static(" ")
yield Static(" ")
yield Static(" ")
yield Static(" ")
yield Static(" ")
yield Static(" ")
yield Static(" ")
yield Static(" ")
# Row 10
yield self.shiftedSeasonsTable
@@ -482,6 +497,7 @@ class PatternDetailsScreen(Screen):
# Row 20
yield Static(" ", classes="seven")
yield build_screen_log_pane()
yield Footer()

View File

@@ -1,13 +1,18 @@
from __future__ import annotations
import logging
import weakref
from collections.abc import Mapping
from dataclasses import dataclass
from rich.cells import cell_len
from rich.measure import measure_renderables
from rich.text import Text
from textual import events
from textual.widgets import Collapsible, RichLog, Static
from .helper import formatRichColor
from .i18n import t
from .pattern_controller import PatternController
from .show_controller import ShowController
from .shifted_season_controller import ShiftedSeasonController
@@ -16,6 +21,156 @@ from .tmdb_controller import TmdbController
from .track_controller import TrackController
SCREEN_LOG_PANE_ID = "screen_log_pane"
SCREEN_LOG_VIEW_ID = "screen_log_view"
SCREEN_LOG_RESIZE_HANDLE_ID = "screen_log_resize_handle"
SCREEN_LOG_HANDLER_NAME = "ffx-screen-log"
SCREEN_LOG_DEFAULT_HEIGHT = 8
SCREEN_LOG_MIN_HEIGHT = 4
SCREEN_LOG_COMPONENT_WIDTH = 16
SCREEN_LOG_LEVEL_WIDTH = 8
_SCREEN_LOG_PANE_ENABLED = False
class ScreenLogHandler(logging.Handler):
"""Mirror logger output into the active screen log pane when available."""
def __init__(self, app) -> None:
super().__init__(level=logging.DEBUG)
self.set_name(SCREEN_LOG_HANDLER_NAME)
self.set_app(app)
def set_app(self, app) -> None:
self._app_ref = weakref.ref(app) if app is not None else lambda: None
def emit(self, record: logging.LogRecord) -> None:
app = self._app_ref()
if app is None:
return
try:
message = str(self.format(record)).strip()
except Exception:
self.handleError(record)
return
if not message:
return
try:
app.call_from_thread(write_screen_log, app.screen, message)
except RuntimeError:
write_screen_log(app.screen, message)
except Exception:
self.handleError(record)
class ScreenLogResizeHandle(Static):
DEFAULT_CSS = """
ScreenLogResizeHandle {
width: 100%;
height: 1;
content-align: center middle;
color: $text-muted;
background: $panel-lighten-1;
}
ScreenLogResizeHandle:hover {
color: $text;
background: $panel-lighten-2;
}
"""
def __init__(self) -> None:
super().__init__(" drag to resize ", id=SCREEN_LOG_RESIZE_HANDLE_ID)
self._drag_active = False
self._drag_origin_screen_y = 0
self._drag_origin_height = SCREEN_LOG_DEFAULT_HEIGHT
def _get_log_pane(self):
return self.parent.parent if self.parent is not None else None
def on_mouse_down(self, event: events.MouseDown) -> None:
if event.button != 1:
return
log_pane = self._get_log_pane()
if log_pane is None:
return
self._drag_active = True
self._drag_origin_screen_y = event.screen_y
self._drag_origin_height = log_pane.get_log_height()
self.capture_mouse()
event.stop()
def on_mouse_move(self, event: events.MouseMove) -> None:
if not self._drag_active:
return
log_pane = self._get_log_pane()
if log_pane is None:
return
next_height = self._drag_origin_height + (
self._drag_origin_screen_y - event.screen_y
)
log_pane.set_log_height(next_height)
event.stop()
def on_mouse_up(self, event: events.MouseUp) -> None:
if not self._drag_active:
return
self._drag_active = False
self.release_mouse()
event.stop()
class ResizableScreenLogPane(Collapsible):
def __init__(self) -> None:
self._log_view = RichLog(
id=SCREEN_LOG_VIEW_ID,
wrap=True,
markup=False,
highlight=False,
auto_scroll=True,
)
self._log_height = SCREEN_LOG_DEFAULT_HEIGHT
self._apply_log_height()
super().__init__(
ScreenLogResizeHandle(),
self._log_view,
title=t("Log"),
collapsed=True,
id=SCREEN_LOG_PANE_ID,
)
self.styles.width = "100%"
def _apply_log_height(self) -> None:
self._log_view.styles.height = self._log_height
self._log_view.styles.width = "100%"
def get_log_height(self) -> int:
return int(self._log_height)
def set_log_height(self, height: int) -> None:
next_height = max(SCREEN_LOG_MIN_HEIGHT, int(height))
try:
available_height = int(self.app.size.height) - 8
except Exception:
available_height = next_height
if available_height > 0:
next_height = min(next_height, available_height)
self._log_height = next_height
self._apply_log_height()
@dataclass(frozen=True)
class ScreenBootstrap:
context: dict
@@ -46,6 +201,48 @@ def build_screen_bootstrap(context: dict) -> ScreenBootstrap:
)
def set_screen_log_pane_enabled(enabled: bool) -> None:
global _SCREEN_LOG_PANE_ENABLED
_SCREEN_LOG_PANE_ENABLED = bool(enabled)
def is_screen_log_pane_enabled() -> bool:
return bool(_SCREEN_LOG_PANE_ENABLED)
def configure_screen_log_handler(logger, app, *, enabled: bool):
if logger is None:
return None
screen_log_handler = next(
(handler for handler in logger.handlers if handler.get_name() == SCREEN_LOG_HANDLER_NAME),
None,
)
if not enabled:
if screen_log_handler is not None:
logger.removeHandler(screen_log_handler)
screen_log_handler.close()
return None
if screen_log_handler is None:
screen_log_handler = ScreenLogHandler(app)
logger.addHandler(screen_log_handler)
elif isinstance(screen_log_handler, ScreenLogHandler):
screen_log_handler.set_app(app)
screen_log_handler.setLevel(logging.DEBUG)
screen_log_handler.setFormatter(
logging.Formatter(
f"%(name)-{SCREEN_LOG_COMPONENT_WIDTH}s "
+ f"%(levelname)-{SCREEN_LOG_LEVEL_WIDTH}s "
+ "%(asctime)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
return screen_log_handler
def build_screen_controllers(
context: dict,
*,
@@ -143,6 +340,48 @@ def update_table_column_label(table, column_key, label) -> None:
table.refresh()
def build_screen_log_pane() -> ResizableScreenLogPane | Static:
"""Create a shared collapsible log pane for screen-local diagnostics."""
if not is_screen_log_pane_enabled():
hidden = Static("", id=f"{SCREEN_LOG_PANE_ID}_disabled")
hidden.display = False
return hidden
return ResizableScreenLogPane()
def toggle_screen_log_pane(screen) -> bool:
"""Toggle the current screen log pane when present."""
try:
logPane = screen.query_one(f"#{SCREEN_LOG_PANE_ID}", Collapsible)
except Exception:
return False
logPane.collapsed = not bool(logPane.collapsed)
return True
def write_screen_log(screen, message: str) -> bool:
"""Append a line to the current screen log pane when present."""
if message is None:
return False
text = str(message).strip()
if not text:
return False
try:
logView = screen.query_one(f"#{SCREEN_LOG_VIEW_ID}", RichLog)
except Exception:
return False
logView.write(text)
return True
def go_back_or_exit(screen) -> None:
"""Pop the current screen when possible, otherwise exit the app."""

View File

@@ -3,7 +3,7 @@ from textual.screen import Screen
from textual.widgets import Footer, Placeholder
from .i18n import t
from .screen_support import go_back_or_exit
from .screen_support import build_screen_log_pane, go_back_or_exit
class SettingsScreen(Screen):
@@ -17,7 +17,15 @@ class SettingsScreen(Screen):
def compose(self) -> ComposeResult:
# Row 1
yield Placeholder(t("Settings Screen"))
yield build_screen_log_pane()
yield Footer()
def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
def action_back(self):
go_back_or_exit(self)

View File

@@ -6,7 +6,7 @@ from textual.containers import Grid
from .i18n import t
from .shifted_season_controller import ShiftedSeasonController
from .screen_support import go_back_or_exit
from .screen_support import build_screen_log_pane, go_back_or_exit
from ffx.model.shifted_season import ShiftedSeason
@@ -67,6 +67,9 @@ class ShiftedSeasonDeleteScreen(Screen):
def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId)
ownerLabel = (
@@ -127,6 +130,7 @@ class ShiftedSeasonDeleteScreen(Screen):
yield Button(t("Delete"), id="delete_button")
yield Button(t("Cancel"), id="cancel_button")
yield build_screen_log_pane()
yield Footer()

View File

@@ -6,7 +6,7 @@ from textual.containers import Grid
from .i18n import t
from .shifted_season_controller import ShiftedSeasonController
from .screen_support import go_back_or_exit
from .screen_support import build_screen_log_pane, go_back_or_exit
from ffx.model.shifted_season import ShiftedSeason
@@ -109,6 +109,9 @@ class ShiftedSeasonDetailsScreen(Screen):
def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
if self.__shiftedSeasonId is not None:
shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId)
@@ -175,6 +178,7 @@ class ShiftedSeasonDetailsScreen(Screen):
# Row 10
yield Static(" ", classes="three")
yield build_screen_log_pane()
yield Footer()

View File

@@ -4,7 +4,7 @@ from textual.containers import Grid
from .i18n import t
from .show_controller import ShowController
from .screen_support import go_back_or_exit
from .screen_support import build_screen_log_pane, go_back_or_exit
# Screen[dict[int, str, int]]
class ShowDeleteScreen(Screen):
@@ -89,6 +89,7 @@ class ShowDeleteScreen(Screen):
yield Button(t("Cancel"), id="cancel_button")
yield build_screen_log_pane()
yield Footer()
@@ -108,5 +109,12 @@ class ShowDeleteScreen(Screen):
if event.button.id == "cancel_button":
self.app.pop_screen()
def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
def action_back(self):
go_back_or_exit(self)

View File

@@ -21,6 +21,7 @@ from .screen_support import (
add_auto_table_column,
build_screen_bootstrap,
build_screen_controllers,
build_screen_log_pane,
go_back_or_exit,
)
@@ -174,6 +175,9 @@ class ShowDetailsScreen(Screen):
def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
if self.__showDescriptor is not None:
showId = int(self.__showDescriptor.getId())
@@ -433,6 +437,7 @@ class ShowDetailsScreen(Screen):
yield Button(t("Cancel"), id="cancel_button")
yield build_screen_log_pane()
yield Footer()

View File

@@ -5,7 +5,12 @@ from rich.text import Text
from .i18n import t
from .show_controller import ShowController
from .screen_support import add_auto_table_column, go_back_or_exit, update_table_column_label
from .screen_support import (
add_auto_table_column,
build_screen_log_pane,
go_back_or_exit,
update_table_column_label,
)
from .show_details_screen import ShowDetailsScreen
from .show_delete_screen import ShowDeleteScreen
@@ -239,6 +244,10 @@ class ShowsScreen(Screen):
def on_mount(self) -> None:
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
for show in self.__sc.getAllShows():
self._add_show_row(show.getDescriptor(self.context))
@@ -278,4 +287,5 @@ class ShowsScreen(Screen):
f = Footer()
f.description = "yolo"
yield build_screen_log_pane()
yield f

View File

@@ -3,7 +3,7 @@ from textual.widgets import Header, Footer, Static, Button
from textual.containers import Grid
from .i18n import t
from .screen_support import go_back_or_exit
from .screen_support import build_screen_log_pane, go_back_or_exit
# Screen[dict[int, str, int]]
@@ -64,6 +64,9 @@ class TagDeleteScreen(Screen):
def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
self.query_one("#keylabel", Static).update(str(self.__key))
self.query_one("#valuelabel", Static).update(str(self.__value))
@@ -92,6 +95,7 @@ class TagDeleteScreen(Screen):
yield Button(t("Delete"), id="delete_button")
yield Button(t("Cancel"), id="cancel_button")
yield build_screen_log_pane()
yield Footer()

View File

@@ -3,7 +3,7 @@ from textual.widgets import Header, Footer, Static, Button, Input
from textual.containers import Grid
from .i18n import t
from .screen_support import go_back_or_exit
from .screen_support import build_screen_log_pane, go_back_or_exit
# Screen[dict[int, str, int]]
@@ -87,6 +87,9 @@ class TagDetailsScreen(Screen):
def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
if self.__key is not None:
self.query_one("#key_input", Input).value = str(self.__key)
@@ -121,6 +124,7 @@ class TagDetailsScreen(Screen):
# Row 6
yield Static(" ", classes="five", id="messagestatic")
yield build_screen_log_pane()
yield Footer(id="footer")

View File

@@ -3,17 +3,20 @@ from enum import Enum
class TrackCodec(Enum):
H265 = {'identifier': 'hevc', 'format': 'h265', 'extension': 'h265' ,'label': 'H.265'}
VP9 = {'identifier': 'vp9', 'format': 'ivf', 'extension': 'ivf' , 'label': 'VP9'}
H265 = {'identifier': 'hevc', 'format': None, 'extension': 'h265' ,'label': 'H.265'}
H264 = {'identifier': 'h264', 'format': 'h264', 'extension': 'h264' ,'label': 'H.264'}
MPEG4 = {'identifier': 'mpeg4', 'format': 'm4v', 'extension': 'm4v' ,'label': 'MPEG-4'}
MPEG2 = {'identifier': 'mpeg2video', 'format': 'mpeg2video', 'extension': 'mpg' ,'label': 'MPEG-2'}
OPUS = {'identifier': 'opus', 'format': 'opus', 'extension': 'opus' , 'label': 'Opus'}
AAC = {'identifier': 'aac', 'format': None, 'extension': 'aac' , 'label': 'AAC'}
AC3 = {'identifier': 'ac3', 'format': 'ac3', 'extension': 'ac3' , 'label': 'AC3'}
EAC3 = {'identifier': 'eac3', 'format': 'eac3', 'extension': 'eac3' , 'label': 'EAC3'}
DTS = {'identifier': 'dts', 'format': 'dts', 'extension': 'dts' , 'label': 'DTS'}
MP3 = {'identifier': 'mp3', 'format': 'mp3', 'extension': 'mp3' , 'label': 'MP3'}
WEBVTT = {'identifier': 'webvtt', 'format': 'webvtt', 'extension': 'vtt' , 'label': 'WebVTT'}
SRT = {'identifier': 'subrip', 'format': 'srt', 'extension': 'srt' , 'label': 'SRT'}
ASS = {'identifier': 'ass', 'format': 'ass', 'extension': 'ass' , 'label': 'ASS'}
TTF = {'identifier': 'ttf', 'format': None, 'extension': 'ttf' , 'label': 'TTF'}

View File

@@ -6,7 +6,7 @@ from textual.containers import Grid
from ffx.track_descriptor import TrackDescriptor
from .i18n import t
from .screen_support import go_back_or_exit
from .screen_support import build_screen_log_pane, go_back_or_exit
# Screen[dict[int, str, int]]
@@ -67,6 +67,9 @@ class TrackDeleteScreen(Screen):
def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
self.query_one("#subindexlabel", Static).update(str(self.__trackDescriptor.getSubIndex()))
self.query_one("#patternlabel", Static).update(str(self.__trackDescriptor.getPatternId()))
self.query_one("#languagelabel", Static).update(str(self.__trackDescriptor.getLanguage().label()))
@@ -118,6 +121,7 @@ class TrackDeleteScreen(Screen):
yield Button(t("Delete"), id="delete_button")
yield Button(t("Cancel"), id="cancel_button")
yield build_screen_log_pane()
yield Footer()

View File

@@ -14,7 +14,13 @@ from .track_descriptor import TrackDescriptor
from .track_disposition import TrackDisposition
from .track_type import TrackType
from .i18n import t
from .screen_support import add_auto_table_column, build_screen_bootstrap, go_back_or_exit, populate_tag_table
from .screen_support import (
add_auto_table_column,
build_screen_bootstrap,
build_screen_log_pane,
go_back_or_exit,
populate_tag_table,
)
class TrackDetailsScreen(Screen):
@@ -128,6 +134,9 @@ class TrackDetailsScreen(Screen):
self.__patternLabel = str(patternLabel)
self.__siblingTrackDescriptors = list(siblingTrackDescriptors or [])
self.__metadataOnly = bool(metadata_only)
self.__applyNormalization = bool(
self.context.get("apply_metadata_normalization", True)
)
if self.__isNew:
self.__trackType = trackType
@@ -152,8 +161,13 @@ class TrackDetailsScreen(Screen):
initial_language = trackDescriptor.getLanguage()
initial_title = trackDescriptor.getTitle()
self.__titleAutoManaged = (
initial_language == IsoLanguage.UNDEFINED and not str(initial_title).strip()
initialTitleEmpty = not str(initial_title).strip()
self.__titleAutoManaged = bool(
initialTitleEmpty
and (
initial_language == IsoLanguage.UNDEFINED
or (self.__metadataOnly and self.__applyNormalization)
)
)
self.__suppressTitleChanged = False
self.__lastAutoTitle = ""
@@ -222,6 +236,9 @@ class TrackDetailsScreen(Screen):
def on_mount(self):
if getattr(self, 'context', {}).get('debug', False):
self.title = f"{self.app.title} - {self.__class__.__name__}"
self.query_one("#index_label", Static).update(
str(self.__index) if self.__index is not None else "-"
)
@@ -256,6 +273,8 @@ class TrackDetailsScreen(Screen):
self.__trackDescriptor.getLanguage()
)
self.query_one("#title_input", Input).value = self.__trackDescriptor.getTitle()
if self.__titleAutoManaged and not self.__trackDescriptor.getTitle().strip():
self._apply_auto_title_for_language(self.__trackDescriptor.getLanguage())
self.updateTags()
if self.__metadataOnly:
@@ -387,6 +406,7 @@ class TrackDetailsScreen(Screen):
# Row 24
yield Static(" ", classes="five", id="messagestatic")
yield build_screen_log_pane()
yield Footer(id="footer")
def getTrackDescriptorFromInput(self):

View File

@@ -7,6 +7,7 @@ import os
from pathlib import Path
import subprocess
import sys
from functools import lru_cache
from typing import Mapping
@@ -95,8 +96,69 @@ def write_vtt(path: Path, lines: tuple[str, ...]) -> Path:
return path
def create_source_fixture(workdir: Path, filename: str, tracks: list[SourceTrackSpec], duration_seconds: int = 1) -> Path:
@lru_cache(maxsize=None)
def _ffmpeg_encoder_is_available(encoder_name: str) -> bool:
completed = subprocess.run(
["ffmpeg", "-encoders"],
capture_output=True,
text=True,
)
if completed.returncode != 0:
return False
encoder_label = str(encoder_name).strip()
for line in completed.stdout.splitlines():
if not line.startswith(" "):
continue
tokens = line.split(maxsplit=2)
if len(tokens) >= 2 and tokens[1] == encoder_label:
return True
return False
def _resolve_fixture_video_encoder(
video_encoder: str,
video_encoder_options: tuple[str, ...],
) -> tuple[str, tuple[str, ...]]:
if video_encoder != "libx264":
return video_encoder, video_encoder_options
if _ffmpeg_encoder_is_available("libx264"):
return video_encoder, video_encoder_options
if _ffmpeg_encoder_is_available("libopenh264"):
# Keep fixture generation software-based when libx264 is missing.
return "libopenh264", ("-pix_fmt", "yuv420p")
return video_encoder, video_encoder_options
def create_source_fixture(
workdir: Path,
filename: str,
tracks: list[SourceTrackSpec],
duration_seconds: int = 1,
*,
video_encoder: str = "libx264",
video_encoder_options: tuple[str, ...] = (
"-preset",
"ultrafast",
"-crf",
"35",
"-pix_fmt",
"yuv420p",
),
audio_encoder: str = "aac",
audio_encoder_options: tuple[str, ...] = ("-b:a", "48k"),
subtitle_encoder: str = "webvtt",
) -> Path:
output_path = workdir / filename
video_encoder, video_encoder_options = _resolve_fixture_video_encoder(
video_encoder,
video_encoder_options,
)
has_video = any(track.track_type == TrackType.VIDEO for track in tracks)
has_audio = any(track.track_type == TrackType.AUDIO for track in tracks)
@@ -189,21 +251,16 @@ def create_source_fixture(workdir: Path, filename: str, tracks: list[SourceTrack
command += map_tokens
command += metadata_tokens
command += disposition_tokens
if has_video:
command += ["-c:v", video_encoder] + list(video_encoder_options)
if has_audio:
command += ["-c:a", audio_encoder] + list(audio_encoder_options)
if subtitle_input_indices:
command += ["-c:s", subtitle_encoder]
command += [
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-crf",
"35",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"48k",
"-c:s",
"webvtt",
"-t",
str(duration_seconds),
"-shortest",

View File

@@ -168,6 +168,40 @@ class CliLazyImportTests(unittest.TestCase):
result["modules"],
)
def test_root_debug_flag_parses_without_loading_runtime_modules(self):
result = self.run_python(
textwrap.dedent(
f"""
import json
import sys
sys.path.insert(0, {str(SRC_ROOT)!r})
import ffx.cli
context = ffx.cli.ffx.make_context(
"ffx",
["--debug", "help"],
resilient_parsing=True,
)
print(json.dumps({{
"debug": context.params["debug"],
"modules": {{
module_name: module_name in sys.modules
for module_name in {HEAVY_MODULES!r}
}},
}}))
"""
)
)
self.assertTrue(result["debug"])
self.assertTrue(
all(not is_loaded for is_loaded in result["modules"].values()),
result["modules"],
)
def test_convert_cut_option_supports_flag_duration_and_start_duration_forms(self):
result = self.run_python(
textwrap.dedent(

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
from pathlib import Path
import sys
import unittest
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 import cli # noqa: E402
from ffx.track_codec import TrackCodec # noqa: E402
from ffx.track_descriptor import TrackDescriptor # noqa: E402
from ffx.track_type import TrackType # noqa: E402
class UnmuxSequenceTests(unittest.TestCase):
def test_h265_video_unmux_uses_annex_b_bitstream_filter_without_forced_format(self):
track_descriptor = TrackDescriptor(
index=0,
sub_index=0,
track_type=TrackType.VIDEO,
codec_name=TrackCodec.H265,
tags={},
disposition_set=set(),
)
sequence = cli.getUnmuxSequence(
track_descriptor,
"input.mp4",
"episode_0_eng",
)
self.assertEqual(
[
"ffmpeg",
"-y",
"-i",
"input.mp4",
"-map",
"0:v:0",
"-c:v",
"copy",
"-bsf:v",
"hevc_mp4toannexb",
"episode_0_eng.h265",
],
sequence,
)
def test_non_h265_unmux_keeps_generic_copy_behavior(self):
track_descriptor = TrackDescriptor(
index=1,
sub_index=0,
track_type=TrackType.SUBTITLE,
codec_name=TrackCodec.SRT,
tags={},
disposition_set=set(),
)
sequence = cli.getUnmuxSequence(
track_descriptor,
"input.mkv",
"episode_1_eng",
)
self.assertEqual(
[
"ffmpeg",
"-y",
"-i",
"input.mkv",
"-map",
"0:s:0",
"-c",
"copy",
"-f",
"srt",
"episode_1_eng.srt",
],
sequence,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import click
from pathlib import Path
import sys
import unittest
@@ -32,6 +33,9 @@ class StaticConfig:
class FfxControllerTests(unittest.TestCase):
def tearDown(self):
FfxController.isFfmpegEncoderAvailable.cache_clear()
def make_context(self, video_encoder: VideoEncoder) -> dict:
return {
"logger": get_ffx_logger(),
@@ -192,6 +196,62 @@ class FfxControllerTests(unittest.TestCase):
self.assertIn("ENCODING_QUALITY=19", commands[0])
mocked_info.assert_any_call("Setting quality 19 from pattern")
def test_generate_h264_tokens_prefers_libx264_when_available(self):
context = self.make_context(VideoEncoder.H264)
target_descriptor, source_descriptor = self.make_media_descriptors()
controller = FfxController(context, target_descriptor, source_descriptor)
with patch.object(
FfxController,
"getSupportedSoftwareH264Encoder",
return_value="libx264",
):
tokens = controller.generateH264Tokens(23)
self.assertEqual(
["-c:v:0", "libx264", "-preset", "slow", "-crf", "23"],
tokens,
)
def test_generate_h264_tokens_falls_back_to_libopenh264_and_logs_warning(self):
context = self.make_context(VideoEncoder.H264)
target_descriptor, source_descriptor = self.make_media_descriptors()
controller = FfxController(context, target_descriptor, source_descriptor)
with (
patch.object(
FfxController,
"getSupportedSoftwareH264Encoder",
return_value="libopenh264",
),
patch.object(context["logger"], "warning") as mocked_warning,
):
tokens = controller.generateH264Tokens(23)
self.assertEqual(
["-c:v:0", "libopenh264", "-pix_fmt", "yuv420p"],
tokens,
)
mocked_warning.assert_called_once_with(
"libx264 encoder unavailable; falling back to libopenh264 for H.264 encoding."
)
def test_generate_h264_tokens_raises_when_no_supported_software_encoder_exists(self):
context = self.make_context(VideoEncoder.H264)
target_descriptor, source_descriptor = self.make_media_descriptors()
controller = FfxController(context, target_descriptor, source_descriptor)
with patch.object(
FfxController,
"getSupportedSoftwareH264Encoder",
return_value=None,
):
with self.assertRaisesRegex(
click.ClickException,
"no supported software H.264 encoder is available",
):
controller.generateH264Tokens(23)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
import unittest
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.file_properties import FileProperties # noqa: E402
from ffx.i18n import set_current_language # noqa: E402
from ffx.logging_utils import get_ffx_logger # noqa: E402
from ffx.track_codec import TrackCodec # noqa: E402
from ffx.track_type import TrackType # noqa: E402
from tests.support.ffx_bundle import SourceTrackSpec, create_source_fixture # noqa: E402
class StaticConfig:
def __init__(self, data: dict):
self._data = data
def getData(self):
return self._data
class FilePropertiesAssetProbeTests(unittest.TestCase):
def tearDown(self):
set_current_language("de")
def test_boruto_webm_probe_recognizes_webm_stream_codecs(self):
context = {
"logger": get_ffx_logger(),
"config": StaticConfig({}),
"language": "de",
"use_pattern": False,
}
set_current_language("de")
with tempfile.TemporaryDirectory() as tmpdir:
media_path = create_source_fixture(
Path(tmpdir),
"fixture.webm",
[
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng"),
SourceTrackSpec(
TrackType.SUBTITLE,
identity="subtitle-2",
language="eng",
subtitle_lines=("Lorem ipsum dolor sit amet.",),
),
],
duration_seconds=3,
video_encoder="libvpx-vp9",
video_encoder_options=("-b:v", "0", "-crf", "45"),
audio_encoder="libopus",
audio_encoder_options=("-b:a", "48k"),
subtitle_encoder="webvtt",
)
file_properties = FileProperties(context, str(media_path))
tracks = file_properties.getMediaDescriptor().getTrackDescriptors()
subtitle_codecs = [
track.getCodec()
for track in tracks
if track.getType() == TrackType.SUBTITLE
]
self.assertIn(TrackCodec.VP9, [track.getCodec() for track in tracks])
self.assertIn(TrackCodec.OPUS, [track.getCodec() for track in tracks])
self.assertTrue(subtitle_codecs)
self.assertTrue(all(codec == TrackCodec.WEBVTT for codec in subtitle_codecs))
if __name__ == "__main__":
unittest.main()

View File

@@ -16,8 +16,10 @@ if str(SRC_ROOT) not in sys.path:
from ffx.logging_utils import ( # noqa: E402
CONSOLE_HANDLER_NAME,
FILE_HANDLER_NAME,
MUTED_CONSOLE_LEVEL,
configure_ffx_logger,
get_ffx_logger,
set_ffx_console_logging_enabled,
)
@@ -81,6 +83,33 @@ class LoggingUtilsTests(unittest.TestCase):
self.cleanup_logger(logger_name)
def test_set_ffx_console_logging_enabled_mutes_and_restores_console_handler(self):
logger_name = "ffx-test-console-mute"
self.cleanup_logger(logger_name)
with tempfile.TemporaryDirectory() as tempdir:
log_path = Path(tempdir) / "ffx.log"
logger = configure_ffx_logger(
str(log_path),
logging.DEBUG,
logging.INFO,
name=logger_name,
)
console_handler = next(
handler for handler in logger.handlers if handler.get_name() == CONSOLE_HANDLER_NAME
)
self.assertEqual(logging.INFO, console_handler.level)
set_ffx_console_logging_enabled(logger, enabled=False)
self.assertEqual(MUTED_CONSOLE_LEVEL, console_handler.level)
set_ffx_console_logging_enabled(logger, enabled=True)
self.assertEqual(logging.INFO, console_handler.level)
self.cleanup_logger(logger_name)
if __name__ == "__main__":
unittest.main()

View File

@@ -247,7 +247,8 @@ class MediaDescriptorChangeSetTests(unittest.TestCase):
self.assertIn("title=German", metadata_tokens)
self.assertNotIn("title=Deutsch", metadata_tokens)
def test_non_subtitle_track_without_title_does_not_get_language_name(self):
def test_audio_track_without_title_gets_language_name_when_normalization_enabled(self):
set_current_language("de")
context = {
"logger": get_ffx_logger(),
"config": StaticConfig({}),
@@ -278,6 +279,73 @@ class MediaDescriptorChangeSetTests(unittest.TestCase):
self.assertIn("-metadata:s:a:0", metadata_tokens)
self.assertIn("language=deu", metadata_tokens)
self.assertIn("title=Deutsch", metadata_tokens)
def test_video_track_without_title_gets_language_name_when_normalization_enabled(self):
set_current_language("de")
context = {
"logger": get_ffx_logger(),
"config": StaticConfig({}),
}
source_track = TrackDescriptor(
index=0,
source_index=0,
sub_index=0,
track_type=TrackType.VIDEO,
tags={"language": "ger"},
)
target_track = TrackDescriptor(
index=0,
source_index=0,
sub_index=0,
track_type=TrackType.VIDEO,
tags={"language": "ger"},
)
change_set = MediaDescriptorChangeSet(
context,
MediaDescriptor(track_descriptors=[target_track]),
MediaDescriptor(track_descriptors=[source_track]),
)
metadata_tokens = change_set.generateMetadataTokens()
self.assertIn("language=deu", metadata_tokens)
self.assertIn("title=Deutsch", metadata_tokens)
def test_changed_track_language_does_not_autofill_title_when_title_already_exists(self):
set_current_language("de")
context = {
"logger": get_ffx_logger(),
"config": StaticConfig({}),
}
source_track = TrackDescriptor(
index=0,
source_index=0,
sub_index=0,
track_type=TrackType.SUBTITLE,
tags={"language": "ger", "title": "Deutsch [FN]"},
)
target_track = TrackDescriptor(
index=0,
source_index=0,
sub_index=0,
track_type=TrackType.SUBTITLE,
tags={"language": "jpn", "title": "Deutsch [FN]"},
)
change_set = MediaDescriptorChangeSet(
context,
MediaDescriptor(track_descriptors=[target_track]),
MediaDescriptor(track_descriptors=[source_track]),
)
metadata_tokens = change_set.generateMetadataTokens()
self.assertIn("language=jpn", metadata_tokens)
self.assertNotIn("title=Japanisch", metadata_tokens)
self.assertNotIn("title=Deutsch", metadata_tokens)
def test_target_only_tracks_still_emit_remove_tokens_for_configured_stream_keys(self):

View File

@@ -15,9 +15,11 @@ if str(SRC_ROOT) not in sys.path:
from ffx.logging_utils import get_ffx_logger # noqa: E402
from ffx.helper import LogLevel # noqa: E402
from ffx.media_descriptor import MediaDescriptor # noqa: E402
from ffx.metadata_editor import ( # noqa: E402
apply_metadata_edits,
build_metadata_edit_command,
build_metadata_edit_context,
create_temporary_output_path,
)
@@ -32,6 +34,16 @@ class StaticConfig:
return {}
class NotificationCollector:
def __init__(self) -> None:
self.messages: list[str] = []
self.levels: list[LogLevel | None] = []
def __call__(self, message: str, level: LogLevel | None = None) -> None:
self.messages.append(message)
self.levels.append(level)
def make_context(*, dry_run: bool = False) -> dict:
return {
"logger": get_ffx_logger(),
@@ -77,15 +89,45 @@ class MetadataEditorTests(unittest.TestCase):
self.assertEqual(".mkv", Path(temporary_path).suffix)
self.assertEqual(Path(source_path).parent, Path(temporary_path).parent)
def test_build_metadata_edit_command_maps_all_streams_and_uses_single_copy_codec(self):
context = build_metadata_edit_context(make_context())
baseline_descriptor = make_descriptor()
draft_descriptor = baseline_descriptor.clone(context=context)
command = build_metadata_edit_command(
context,
"/tmp/example.mkv",
"/tmp/.edit.mkv",
baseline_descriptor,
draft_descriptor,
)
self.assertEqual(1, command.count("-map"))
self.assertEqual(1, command.count("-c"))
self.assertNotIn("-c:v:0", command)
self.assertNotIn("-c:a:0", command)
self.assertNotIn("-c:s:0", command)
self.assertEqual(
["-map", "0", "-c", "copy"],
command[command.index("-map"):command.index("-c") + 2],
)
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"
expected_command = build_metadata_edit_command(
build_metadata_edit_context(context),
source_path,
"/tmp/.edit.mkv",
baseline_descriptor,
draft_descriptor,
)
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.executeProcess", return_value=("", "", 0)) as mocked_execute,
patch("ffx.metadata_editor.os.replace") as mocked_replace,
):
result = apply_metadata_edits(
@@ -95,32 +137,43 @@ class MetadataEditorTests(unittest.TestCase):
draft_descriptor,
)
mocked_run_job.assert_called_once_with(
source_path,
"/tmp/.edit.mkv",
targetFormat="",
chainIteration=[],
)
mocked_execute.assert_called_once_with(expected_command, context=build_metadata_edit_context(context))
mocked_replace.assert_called_once_with("/tmp/.edit.mkv", source_path)
self.assertEqual(
{
"applied": True,
"dry_run": False,
"target_path": source_path,
"command_sequence": expected_command,
},
{
"applied": result["applied"],
"dry_run": result["dry_run"],
"target_path": result["target_path"],
"command_sequence": result["command_sequence"],
},
result,
)
self.assertIn("timings", result)
self.assertIn("ffmpeg_seconds", result["timings"])
self.assertIn("replace_seconds", result["timings"])
self.assertIn("write_seconds", result["timings"])
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)
notifications = NotificationCollector()
expected_command = build_metadata_edit_command(
build_metadata_edit_context(context),
"/tmp/example.mkv",
"/tmp/.edit.mkv",
baseline_descriptor,
draft_descriptor,
)
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.executeProcess") as mocked_execute,
patch("ffx.metadata_editor.os.replace") as mocked_replace,
):
result = apply_metadata_edits(
@@ -128,19 +181,59 @@ class MetadataEditorTests(unittest.TestCase):
"/tmp/example.mkv",
baseline_descriptor,
draft_descriptor,
loggingHandler = notifications,
)
mocked_run_job.assert_called_once()
mocked_execute.assert_not_called()
mocked_replace.assert_not_called()
mocked_remove.assert_called_once_with("/tmp/.edit.mkv")
self.assertEqual(["ffmpeg dry-run prepared."], notifications.messages)
self.assertEqual([None], notifications.levels)
self.assertEqual(
{
"applied": False,
"dry_run": True,
"target_path": "/tmp/.edit.mkv",
"command_sequence": expected_command,
},
{
"applied": result["applied"],
"dry_run": result["dry_run"],
"target_path": result["target_path"],
"command_sequence": result["command_sequence"],
},
result,
)
self.assertEqual(
{
"ffmpeg_seconds": 0.0,
"replace_seconds": 0.0,
"write_seconds": 0.0,
},
result["timings"],
)
def test_apply_metadata_edits_notifies_with_command_when_verbose(self):
context = make_context()
context["verbosity"] = 1
baseline_descriptor = make_descriptor()
draft_descriptor = baseline_descriptor.clone(context=context)
notifications = NotificationCollector()
with (
patch("ffx.metadata_editor.create_temporary_output_path", return_value="/tmp/.edit.mkv"),
patch("ffx.metadata_editor.executeProcess", return_value=("", "", 0)),
patch("ffx.metadata_editor.os.replace"),
):
apply_metadata_edits(
context,
"/tmp/example.mkv",
baseline_descriptor,
draft_descriptor,
loggingHandler = notifications,
)
self.assertEqual(1, len(notifications.messages))
self.assertTrue(notifications.messages[0].startswith("ffmpeg: ffmpeg "))
self.assertEqual([LogLevel.DEBUG], notifications.levels)
if __name__ == "__main__":

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
import logging
import sys
import unittest
from unittest.mock import patch
@@ -57,9 +58,38 @@ class FakeScreen:
self.app = FakeApp(screen_stack)
class FakeRichLog:
def __init__(self):
self.messages = []
def write(self, message):
self.messages.append(message)
class FakeScreenWithLog:
def __init__(self):
self.log_view = FakeRichLog()
def query_one(self, selector, _widget_type=None):
if selector == f"#{screen_support.SCREEN_LOG_VIEW_ID}":
return self.log_view
raise LookupError(selector)
class FakeThreadedApp:
def __init__(self, screen):
self.screen = screen
self.calls = []
def call_from_thread(self, func, *args):
self.calls.append((func, args))
return func(*args)
class ScreenSupportTests(unittest.TestCase):
def tearDown(self):
set_current_language("de")
screen_support.set_screen_log_pane_enabled(False)
def make_context(self):
return {
@@ -168,6 +198,63 @@ class ScreenSupportTests(unittest.TestCase):
self.assertGreater(len(translated), 8)
self.assertEqual(len(translated) + 2, screen_support.localized_column_width(translated, 8))
def test_build_screen_log_pane_is_hidden_when_debug_mode_is_disabled(self):
screen_support.set_screen_log_pane_enabled(False)
log_pane = screen_support.build_screen_log_pane()
self.assertFalse(log_pane.display)
def test_build_screen_log_pane_is_collapsed_when_debug_mode_is_enabled(self):
screen_support.set_screen_log_pane_enabled(True)
log_pane = screen_support.build_screen_log_pane()
self.assertIsInstance(log_pane, screen_support.ResizableScreenLogPane)
self.assertEqual(screen_support.SCREEN_LOG_PANE_ID, log_pane.id)
self.assertTrue(log_pane.collapsed)
def test_resizable_screen_log_pane_clamps_height_to_minimum(self):
log_pane = screen_support.ResizableScreenLogPane()
log_pane.set_log_height(1)
self.assertEqual(screen_support.SCREEN_LOG_MIN_HEIGHT, log_pane.get_log_height())
def test_configure_screen_log_handler_routes_logger_messages_to_active_screen(self):
logger_name = "ffx-test-screen-log-handler"
logger = logging.getLogger(logger_name)
logger.setLevel(logging.DEBUG)
logger.propagate = False
for handler in list(logger.handlers):
logger.removeHandler(handler)
handler.close()
screen = FakeScreenWithLog()
app = FakeThreadedApp(screen)
try:
handler = screen_support.configure_screen_log_handler(
logger,
app,
enabled=True,
)
self.assertIsNotNone(handler)
logger.info("hello pane")
self.assertEqual(1, len(screen.log_view.messages))
self.assertRegex(
screen.log_view.messages[0],
r"^ffx-test-screen-log-handler\s+INFO\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \| hello pane$",
)
finally:
screen_support.configure_screen_log_handler(logger, app, enabled=False)
for handler in list(logger.handlers):
logger.removeHandler(handler)
handler.close()
if __name__ == "__main__":
unittest.main()

View File

@@ -14,10 +14,12 @@ if str(SRC_ROOT) not in sys.path:
from ffx.audio_layout import AudioLayout # noqa: E402
from ffx.helper import DIFF_ADDED_KEY # noqa: E402
from ffx.iso_language import IsoLanguage # noqa: E402
from ffx.logging_utils import get_ffx_logger # noqa: E402
from ffx.inspect_details_screen import InspectDetailsScreen # noqa: E402
from ffx.i18n import set_current_language # noqa: E402
from ffx.media_descriptor import MediaDescriptor # noqa: E402
from ffx.media_edit_screen import MediaEditScreen # noqa: E402
from ffx.pattern_details_screen import PatternDetailsScreen # noqa: E402
from ffx.show_descriptor import ShowDescriptor # noqa: E402
@@ -89,16 +91,21 @@ class FakeTagTable:
class FakeMediaDescriptor:
def __init__(self, track_descriptors):
def __init__(self, track_descriptors, tags=None):
self._track_descriptors = list(track_descriptors)
self._tags = dict(tags or {})
def getTrackDescriptors(self):
return list(self._track_descriptors)
def getTags(self):
return dict(self._tags)
class FakeValueWidget:
def __init__(self, value):
self.value = value
self.disabled = False
class FakeInputWidget:
@@ -106,10 +113,21 @@ class FakeInputWidget:
self.value = value
class FakeStaticWidget:
def __init__(self, value=""):
self.value = value
def update(self, value):
self.value = value
class FakeSelectionListWidget:
def __init__(self, selected):
self.selected = selected
def add_option(self, _option):
return None
def make_track_descriptor(index, sub_index, track_type):
return TrackDescriptor(
@@ -244,6 +262,49 @@ class TagTableScreenStateTests(unittest.TestCase):
self.assertEqual("Preset", widgets["#title_input"].value)
def test_track_details_screen_metadata_only_mount_shows_normalized_title_preview(self):
set_current_language("de")
screen = object.__new__(TrackDetailsScreen)
screen._TrackDetailsScreen__index = 2
screen._TrackDetailsScreen__subIndex = 0
screen._TrackDetailsScreen__patternLabel = "demo"
screen._TrackDetailsScreen__trackType = TrackType.AUDIO
screen._TrackDetailsScreen__audioLayout = AudioLayout.LAYOUT_STEREO
screen._TrackDetailsScreen__trackDescriptor = TrackDescriptor(
index=2,
source_index=2,
sub_index=0,
track_type=TrackType.AUDIO,
codec_name=TrackCodec.DTS,
audio_layout=AudioLayout.LAYOUT_STEREO,
tags={"language": "ger"},
)
screen._TrackDetailsScreen__metadataOnly = True
screen._TrackDetailsScreen__titleAutoManaged = True
screen._TrackDetailsScreen__suppressTitleChanged = False
screen._TrackDetailsScreen__lastAutoTitle = ""
screen._TrackDetailsScreen__removeTrackKeys = []
screen._TrackDetailsScreen__ignoreTrackKeys = []
screen._TrackDetailsScreen__draftTrackTags = {}
screen._TrackDetailsScreen__tagRowData = {}
screen.updateTags = lambda: None
widgets = {
"#index_label": FakeStaticWidget(),
"#subindex_label": FakeStaticWidget(),
"#pattern_label": FakeStaticWidget(),
"#type_select": FakeValueWidget(None),
"#audio_layout_select": FakeValueWidget(None),
"#dispositions_selection_list": FakeSelectionListWidget(set()),
"#language_select": FakeValueWidget(None),
"#title_input": FakeInputWidget(""),
}
screen.query_one = lambda selector, _widget_type=None: widgets[selector]
screen.on_mount()
self.assertEqual("Deutsch", widgets["#title_input"].value)
def test_track_details_screen_language_options_are_sorted_by_localized_label(self):
set_current_language("de")
@@ -326,12 +387,177 @@ class TagTableScreenStateTests(unittest.TestCase):
screen.tracksTable = FakeTagTable()
screen._sourceMediaDescriptor = FakeMediaDescriptor([first_track])
screen._trackRowData = {}
screen._applyNormalization = False
screen.updateTracks()
self.assertEqual(9, len(screen.tracksTable.columns))
self.assertIn("A much longer updated title", screen.tracksTable.rows["row-0"])
def test_media_edit_screen_shows_normalized_audio_title_preview(self):
set_current_language("de")
audio_track = TrackDescriptor(
index=1,
source_index=1,
sub_index=0,
track_type=TrackType.AUDIO,
codec_name=TrackCodec.DTS,
audio_layout=AudioLayout.LAYOUT_STEREO,
tags={"language": "ger"},
)
screen = object.__new__(MediaEditScreen)
screen.tracksTable = FakeTagTable()
screen._sourceMediaDescriptor = FakeMediaDescriptor([audio_track])
screen._trackRowData = {}
screen._applyNormalization = True
screen.updateTracks()
self.assertIn("Deutsch", screen.tracksTable.rows["row-0"])
def test_media_edit_screen_shows_normalized_video_title_preview(self):
set_current_language("de")
video_track = TrackDescriptor(
index=0,
source_index=0,
sub_index=0,
track_type=TrackType.VIDEO,
codec_name=TrackCodec.H264,
tags={"language": "ger"},
)
screen = object.__new__(MediaEditScreen)
screen.tracksTable = FakeTagTable()
screen._sourceMediaDescriptor = FakeMediaDescriptor([video_track])
screen._trackRowData = {}
screen._applyNormalization = True
screen.updateTracks()
self.assertIn("Deutsch", screen.tracksTable.rows["row-0"])
def test_media_edit_screen_toggle_normalization_refreshes_tracks(self):
screen = object.__new__(MediaEditScreen)
screen._applyNormalization = False
calls = []
screen.setApplyNormalization = lambda enabled: (
setattr(screen, "_applyNormalization", bool(enabled)),
calls.append("setApplyNormalization"),
)
screen.updateToggleButtons = lambda: calls.append("updateToggleButtons")
screen.updateTracks = lambda: calls.append("updateTracks")
screen.updateDifferences = lambda: calls.append("updateDifferences")
screen.setMessage = lambda _message: calls.append("setMessage")
screen.action_toggle_normalization()
self.assertEqual(
[
"setApplyNormalization",
"updateToggleButtons",
"updateTracks",
"updateDifferences",
"setMessage",
],
calls,
)
def test_media_edit_screen_handle_edit_track_updates_draft_descriptor(self):
original_track = TrackDescriptor(
index=1,
source_index=1,
sub_index=0,
track_type=TrackType.SUBTITLE,
codec_name=TrackCodec.UNKNOWN,
tags={"language": "ger"},
)
context = {"logger": get_ffx_logger()}
updated_track = original_track.clone(context=context)
updated_track.getTags()["language"] = "eng"
screen = object.__new__(MediaEditScreen)
screen.context = context
screen._sourceMediaDescriptor = MediaDescriptor(
context=context,
track_descriptors=[original_track],
)
calls = []
screen.setMessage = lambda _message: calls.append("setMessage")
screen.refreshAfterDraftChange = lambda: calls.append("refreshAfterDraftChange")
screen.handle_edit_track(updated_track)
self.assertEqual(
"eng",
screen._sourceMediaDescriptor.getTrackDescriptors()[0].getTags()["language"],
)
self.assertEqual(
["setMessage", "refreshAfterDraftChange"],
calls,
)
def test_media_edit_screen_screen_resume_refreshes_draft_tables(self):
screen = object.__new__(MediaEditScreen)
screen.tracksTable = FakeTagTable()
calls = []
screen.refreshAfterDraftChange = lambda: calls.append("refreshAfterDraftChange")
screen.updateToggleButtons = lambda: calls.append("updateToggleButtons")
screen.on_screen_resume(None)
self.assertEqual(
["refreshAfterDraftChange", "updateToggleButtons"],
calls,
)
def test_pattern_details_screen_screen_resume_refreshes_tables(self):
screen = object.__new__(PatternDetailsScreen)
screen.tracksTable = FakeTagTable()
screen.tagsTable = FakeTagTable()
screen.shiftedSeasonsTable = FakeTagTable()
screen._PatternDetailsScreen__pattern = object()
calls = []
screen.updateTags = lambda: calls.append("updateTags")
screen.updateTracks = lambda: calls.append("updateTracks")
screen.updateShiftedSeasons = lambda: calls.append("updateShiftedSeasons")
screen.on_screen_resume(None)
self.assertEqual(
["updateTags", "updateTracks", "updateShiftedSeasons"],
calls,
)
def test_inspect_details_screen_handle_edit_pattern_refreshes_even_without_result(self):
screen = object.__new__(InspectDetailsScreen)
calls = []
screen.reloadProperties = lambda reset_draft=True: calls.append(
("reloadProperties", reset_draft)
)
screen._currentPattern = None
screen.updateMediaTags = lambda: calls.append("updateMediaTags")
screen.updateTracks = lambda: calls.append("updateTracks")
screen.updateDifferences = lambda: calls.append("updateDifferences")
screen.handle_edit_pattern(None)
self.assertEqual(
[
("reloadProperties", True),
"updateMediaTags",
"updateTracks",
"updateDifferences",
],
calls,
)
def test_pattern_details_screen_reads_selected_shifted_season_from_row_mapping(self):
screen = object.__new__(PatternDetailsScreen)
screen.shiftedSeasonsTable = FakeTagTable()
@@ -438,6 +664,127 @@ class TagTableScreenStateTests(unittest.TestCase):
self.assertNotIn(placeholder_key, screen._showRowData)
self.assertEqual(0, screen.getRowIndexFromShowId(8))
def test_inspect_details_screen_update_tracks_shows_target_pattern_tracks(self):
source_track = TrackDescriptor(
index=1,
source_index=1,
sub_index=0,
track_type=TrackType.SUBTITLE,
codec_name=TrackCodec.UNKNOWN,
tags={"language": "ger", "title": "German Full"},
)
target_track = TrackDescriptor(
index=1,
source_index=1,
sub_index=0,
track_type=TrackType.SUBTITLE,
codec_name=TrackCodec.UNKNOWN,
tags={"language": "eng", "title": "English Full"},
)
screen = object.__new__(InspectDetailsScreen)
screen.tracksTable = FakeTagTable()
screen._sourceMediaDescriptor = FakeMediaDescriptor([source_track])
screen._targetMediaDescriptor = FakeMediaDescriptor([target_track])
screen._currentPattern = object()
screen._trackRowData = {}
screen._applyNormalization = False
screen.updateTracks()
self.assertIn("English Full", screen.tracksTable.rows["row-0"])
self.assertIs(target_track, screen.getSelectedTrackDescriptor())
def test_inspect_details_screen_maps_target_selection_back_to_source_track(self):
source_track = TrackDescriptor(
index=3,
source_index=7,
sub_index=1,
track_type=TrackType.SUBTITLE,
codec_name=TrackCodec.UNKNOWN,
tags={"language": "ger"},
)
target_track = TrackDescriptor(
index=1,
source_index=7,
sub_index=0,
track_type=TrackType.SUBTITLE,
codec_name=TrackCodec.UNKNOWN,
tags={"language": "eng"},
)
screen = object.__new__(InspectDetailsScreen)
screen.tracksTable = FakeTagTable()
screen._sourceMediaDescriptor = FakeMediaDescriptor([source_track])
screen._targetMediaDescriptor = FakeMediaDescriptor([target_track])
screen._currentPattern = object()
screen._trackRowData = {}
screen._applyNormalization = False
screen.updateTracks()
self.assertIs(source_track, screen.getTrackEditSourceDescriptor())
def test_inspect_details_screen_action_update_pattern_uses_existing_change_set_before_reload(self):
class _FakePattern:
def getPattern(self):
return r"demo_(s[0-9]+e[0-9]+)\.mkv"
def getId(self):
return 9
class _FakeTagController:
def __init__(self, calls):
self._calls = calls
def deleteMediaTagByKey(self, pattern_id, key):
self._calls.append(("deleteMediaTagByKey", pattern_id, key))
calls = []
screen = object.__new__(InspectDetailsScreen)
screen._currentPattern = _FakePattern()
screen._mediaChangeSetObj = {
"tags": {
DIFF_ADDED_KEY: {"TITLE": "Demo"},
}
}
screen._tac = _FakeTagController(calls)
screen._tc = type(
"_FakeTrackController",
(),
{
"addTrack": staticmethod(lambda *_args, **_kwargs: None),
"deleteTrack": staticmethod(lambda *_args, **_kwargs: None),
"setDispositionState": staticmethod(lambda *_args, **_kwargs: None),
},
)()
screen._sourceMediaDescriptor = FakeMediaDescriptor([], tags={})
screen._targetMediaDescriptor = FakeMediaDescriptor([])
screen.getPatternObjFromInput = lambda: {
"show_id": 1,
"pattern": r"demo_(s[0-9]+e[0-9]+)\.mkv",
}
screen.reloadProperties = lambda reset_draft=True: calls.append(
("reloadProperties", reset_draft)
)
screen.updateMediaTags = lambda: calls.append("updateMediaTags")
screen.updateTracks = lambda: calls.append("updateTracks")
screen.updateDifferences = lambda: calls.append("updateDifferences")
screen.action_update_pattern()
self.assertEqual(
[
("deleteMediaTagByKey", 9, "TITLE"),
("reloadProperties", True),
"updateMediaTags",
"updateTracks",
"updateDifferences",
],
calls,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from pathlib import Path
import sys
import unittest
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.track_codec import TrackCodec # noqa: E402
class TrackCodecIdentificationTests(unittest.TestCase):
def test_identify_modern_webm_codecs(self):
self.assertEqual(TrackCodec.VP9, TrackCodec.identify("vp9"))
self.assertEqual(TrackCodec.OPUS, TrackCodec.identify("opus"))
self.assertEqual(TrackCodec.WEBVTT, TrackCodec.identify("webvtt"))
if __name__ == "__main__":
unittest.main()