fix: stop ffmpeg cmd windows, refactor ffmpeg_checker (#855)

* fix: remove log statement as it is redundant (#840)

* refactor: rework ffmpeg_checker.py

Move backend logic from ffmpeg_checker.py to vendored/ffmpeg.py, add translation strings for ffmpeg_checker, update vendored/ffmpeg.py

* fix: stop ffmpeg cmd windows, fix version outputs

* chore: ensure stdout is cast to str

---------

Co-authored-by: Jann Stute <46534683+Computerdores@users.noreply.github.com>
This commit is contained in:
Travis Abendshien
2025-03-20 01:09:02 -07:00
committed by GitHub
parent 0701a45e75
commit 880ca07a6f
7 changed files with 141 additions and 71 deletions

View File

@@ -7,7 +7,7 @@ from pathlib import Path
import ffmpeg
from tagstudio.qt.helpers.vendored.ffmpeg import _probe
from tagstudio.qt.helpers.vendored.ffmpeg import probe
def is_readable_video(filepath: Path | str):
@@ -19,8 +19,8 @@ def is_readable_video(filepath: Path | str):
filepath (Path | str): The filepath of the video to check.
"""
try:
probe = _probe(Path(filepath))
for stream in probe["streams"]:
result = probe(Path(filepath))
for stream in result["streams"]:
# DRM check
if stream.get("codec_tag_string") in [
"drma",

View File

@@ -84,3 +84,79 @@ def silent_Popen( # noqa: N802
pipesize=pipesize,
process_group=process_group,
)
def silent_run( # noqa: N802
args,
bufsize=-1,
executable=None,
stdin=None,
stdout=None,
stderr=None,
preexec_fn=None,
close_fds=True,
shell=False,
cwd=None,
env=None,
universal_newlines=None,
startupinfo=None,
creationflags=0,
restore_signals=True,
start_new_session=False,
pass_fds=(),
*,
capture_output=False,
group=None,
extra_groups=None,
user=None,
umask=-1,
encoding=None,
errors=None,
text=None,
pipesize=-1,
process_group=None,
):
"""Call subprocess.run without creating a console window."""
if sys.platform == "win32":
creationflags |= subprocess.CREATE_NO_WINDOW
import ctypes
ctypes.windll.kernel32.SetDllDirectoryW(None)
elif (
sys.platform == "linux"
or sys.platform.startswith("freebsd")
or sys.platform.startswith("openbsd")
):
# pass clean environment to the subprocess
env = os.environ
original_env = env.get("LD_LIBRARY_PATH_ORIG")
env["LD_LIBRARY_PATH"] = original_env if original_env else ""
return subprocess.run(
args=args,
bufsize=bufsize,
executable=executable,
stdin=stdin,
stdout=stdout,
stderr=stderr,
preexec_fn=preexec_fn,
close_fds=close_fds,
shell=shell,
cwd=cwd,
env=env,
startupinfo=startupinfo,
creationflags=creationflags,
restore_signals=restore_signals,
start_new_session=start_new_session,
pass_fds=pass_fds,
capture_output=capture_output,
group=group,
extra_groups=extra_groups,
user=user,
umask=umask,
encoding=encoding,
errors=errors,
text=text,
pipesize=pipesize,
process_group=process_group,
)

View File

@@ -2,15 +2,16 @@
# Licensed under the GPL-3.0 License.
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
import contextlib
import json
import platform
import shutil
import subprocess
from shutil import which
import ffmpeg
import structlog
from tagstudio.qt.helpers.silent_popen import silent_Popen
from tagstudio.qt.helpers.silent_popen import silent_Popen, silent_run
logger = structlog.get_logger(__name__)
@@ -21,10 +22,12 @@ def _get_ffprobe_location() -> str:
cmd: str = "ffprobe"
if platform.system() == "Darwin":
for loc in FFMPEG_MACOS_LOCATIONS:
if shutil.which(loc + cmd):
if which(loc + cmd):
cmd = loc + cmd
break
logger.info(f"[FFMPEG] Using FFprobe location: {cmd}")
logger.info(
f"[FFmpeg] Using FFprobe location: {cmd}{' (Found)' if which(cmd) else ' (Not Found)'}"
)
return cmd
@@ -32,10 +35,12 @@ def _get_ffmpeg_location() -> str:
cmd: str = "ffmpeg"
if platform.system() == "Darwin":
for loc in FFMPEG_MACOS_LOCATIONS:
if shutil.which(loc + cmd):
if which(loc + cmd):
cmd = loc + cmd
break
logger.info(f"[FFMPEG] Using FFmpeg location: {cmd}")
logger.info(
f"[FFmpeg] Using FFmpeg location: {cmd}{' (Found)' if which(cmd) else ' (Not Found)'}"
)
return cmd
@@ -43,7 +48,7 @@ FFPROBE_CMD = _get_ffprobe_location()
FFMPEG_CMD = _get_ffmpeg_location()
def _probe(filename, cmd=FFPROBE_CMD, timeout=None, **kwargs):
def probe(filename, cmd=FFPROBE_CMD, timeout=None, **kwargs):
"""Run ffprobe on the specified file and return a JSON representation of the output.
Raises:
@@ -65,3 +70,22 @@ def _probe(filename, cmd=FFPROBE_CMD, timeout=None, **kwargs):
if p.returncode != 0:
raise ffmpeg.Error("ffprobe", out, err)
return json.loads(out.decode("utf-8"))
def version():
"""Checks the version of FFmpeg and FFprobe and returns None if they dont exist."""
version: dict[str, str | None] = {"ffmpeg": None, "ffprobe": None}
if which(FFMPEG_CMD):
ret = silent_run([FFMPEG_CMD, "-version"], shell=False, capture_output=True, text=True)
if ret.returncode == 0:
with contextlib.suppress(Exception):
version["ffmpeg"] = str(ret.stdout).split(" ")[2]
if which(FFPROBE_CMD):
ret = silent_run([FFPROBE_CMD, "-version"], shell=False, capture_output=True, text=True)
if ret.returncode == 0:
with contextlib.suppress(Exception):
version["ffprobe"] = str(ret.stdout).split(" ")[2]
return version

View File

@@ -21,7 +21,7 @@ from PySide6.QtWidgets import (
from tagstudio.core.constants import VERSION, VERSION_BRANCH
from tagstudio.core.enums import Theme
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.modals.ffmpeg_checker import FfmpegChecker
from tagstudio.qt.helpers.vendored import ffmpeg
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
@@ -31,7 +31,6 @@ class AboutModal(QWidget):
super().__init__()
self.setWindowTitle(Translations["about.title"])
self.fc: FfmpegChecker = FfmpegChecker()
self.rm: ResourceManager = ResourceManager()
# TODO: There should be a global button theme somewhere.
@@ -82,7 +81,7 @@ class AboutModal(QWidget):
self.desc_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# System Info ----------------------------------------------------------
ff_version = self.fc.version()
ff_version = ffmpeg.version()
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
missing = Translations["generic.missing"]

View File

@@ -1,5 +1,3 @@
import contextlib
import subprocess
from shutil import which
import structlog
@@ -7,7 +5,9 @@ from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMessageBox
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.helpers.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
@@ -20,10 +20,11 @@ class FfmpegChecker(QMessageBox):
def __init__(self):
super().__init__()
self.setWindowTitle("Warning: Missing dependency")
self.setText("Warning: Could not find FFmpeg installation")
ffmpeg = "FFmpeg"
ffprobe = "FFprobe"
title = Translations.format("dependency.missing.title", dependency=ffmpeg)
self.setWindowTitle(title)
self.setIcon(QMessageBox.Icon.Warning)
# Blocks other application interactions until resolved
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setStandardButtons(
@@ -34,52 +35,19 @@ class FfmpegChecker(QMessageBox):
self.setDefaultButton(QMessageBox.StandardButton.Ignore)
# Enables the cancel button but hides it to allow for click X to close dialog
self.button(QMessageBox.StandardButton.Cancel).hide()
self.button(QMessageBox.StandardButton.Help).clicked.connect(
lambda: QDesktopServices.openUrl(QUrl(self.HELP_URL))
)
self.ffmpeg = False
self.ffprobe = False
def installed(self):
"""Checks if both FFmpeg and FFprobe are installed and in the PATH."""
if which(FFMPEG_CMD):
self.ffmpeg = True
if which(FFPROBE_CMD):
self.ffprobe = True
logger.info("FFmpeg found: {self.ffmpeg}, FFprobe found: {self.ffprobe}")
return self.ffmpeg and self.ffprobe
def version(self):
"""Checks the version of ffprobe and ffmpeg and returns None if they dont exist."""
version: dict[str, str | None] = {"ffprobe": None, "ffmpeg": None}
self.installed()
if self.ffprobe:
ret = subprocess.run(
[FFPROBE_CMD, "-show_program_version"], shell=False, capture_output=True, text=True
)
if ret.returncode == 0:
with contextlib.suppress(Exception):
version["ffprobe"] = ret.stdout.split("\n")[1].replace("-", "=").split("=")[1]
if self.ffmpeg:
ret = subprocess.run(
[FFMPEG_CMD, "-version"], shell=False, capture_output=True, text=True
)
if ret.returncode == 0:
with contextlib.suppress(Exception):
version["ffmpeg"] = ret.stdout.replace("-", " ").split(" ")[2]
return version
def show_warning(self):
"""Displays the warning to the user and awaits response."""
missing = "FFmpeg"
# If ffmpeg is installed but not ffprobe
if not self.ffprobe and self.ffmpeg:
missing = "FFprobe"
self.setText(f"Warning: Could not find {missing} installation")
self.setInformativeText(f"{missing} is required for multimedia thumbnails and playback")
# Shows the dialog
selection = self.exec()
# Selection will either be QMessageBox.Help or (QMessageBox.Ignore | QMessageBox.Cancel)
if selection == QMessageBox.StandardButton.Help:
QDesktopServices.openUrl(QUrl(self.HELP_URL))
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
missing = f"<span style='color:{red}'>{Translations["generic.missing"]}</span>"
found = f"<span style='color:{green}'>{Translations['about.module.found']}</span>"
status = Translations.format(
"ffmpeg.missing.status",
ffmpeg=ffmpeg,
ffmpeg_status=found if which(FFMPEG_CMD) else missing,
ffprobe=ffprobe,
ffprobe_status=found if which(FFPROBE_CMD) else missing,
)
self.setText(f"{Translations["ffmpeg.missing.description"]}<br><br>{status}")

View File

@@ -19,6 +19,7 @@ import sys
import time
from pathlib import Path
from queue import Queue
from shutil import which
from warnings import catch_warnings
import structlog
@@ -76,6 +77,7 @@ from tagstudio.qt.flowlayout import FlowLayout
from tagstudio.qt.helpers.custom_runnable import CustomRunnable
from tagstudio.qt.helpers.file_deleter import delete_file
from tagstudio.qt.helpers.function_iterator import FunctionIterator
from tagstudio.qt.helpers.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
from tagstudio.qt.main_window import Ui_MainWindow
from tagstudio.qt.modals.about import AboutModal
from tagstudio.qt.modals.build_tag import BuildTagPanel
@@ -676,11 +678,9 @@ class QtDriver(DriverMixin, QObject):
if path_result.success and path_result.library_path:
self.open_library(path_result.library_path)
# check ffmpeg and show warning if not
# NOTE: Does this need to use self?
self.ffmpeg_checker = FfmpegChecker()
if not self.ffmpeg_checker.installed():
self.ffmpeg_checker.show_warning()
# Check if FFmpeg or FFprobe are missing and show warning if so
if not which(FFMPEG_CMD) or not which(FFPROBE_CMD):
FfmpegChecker().show()
app.exec()
self.shutdown()

View File

@@ -23,6 +23,7 @@
"color.primary": "Primary Color",
"color.secondary": "Secondary Color",
"color.title.no_color": "No Color",
"dependency.missing.title": "{dependency} Not Found",
"drop_import.description": "The following files match file paths that already exist in the library",
"drop_import.duplicates_choice.plural": "The following {count} files match file paths that already exist in the library.",
"drop_import.duplicates_choice.singular": "The following file matches a file path that already exists in the library.",
@@ -62,6 +63,8 @@
"entries.unlinked.scanning": "Scanning Library for Unlinked Entries...",
"entries.unlinked.search_and_relink": "&Search && Relink",
"entries.unlinked.title": "Fix Unlinked Entries",
"ffmpeg.missing.description": "FFmpeg and/or FFprobe were not found. FFmpeg is required for multimedia playback and thumbnails.",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
"field.copy": "Copy Field",
"field.edit": "Edit Field",
"field.paste": "Paste Field",