diff --git a/src/tagstudio/qt/helpers/file_tester.py b/src/tagstudio/qt/helpers/file_tester.py index 6db0b37b..2f63eef9 100644 --- a/src/tagstudio/qt/helpers/file_tester.py +++ b/src/tagstudio/qt/helpers/file_tester.py @@ -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", diff --git a/src/tagstudio/qt/helpers/silent_popen.py b/src/tagstudio/qt/helpers/silent_popen.py index ad251c41..92a75496 100644 --- a/src/tagstudio/qt/helpers/silent_popen.py +++ b/src/tagstudio/qt/helpers/silent_popen.py @@ -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, + ) diff --git a/src/tagstudio/qt/helpers/vendored/ffmpeg.py b/src/tagstudio/qt/helpers/vendored/ffmpeg.py index efeefa07..4fd5184f 100644 --- a/src/tagstudio/qt/helpers/vendored/ffmpeg.py +++ b/src/tagstudio/qt/helpers/vendored/ffmpeg.py @@ -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 diff --git a/src/tagstudio/qt/modals/about.py b/src/tagstudio/qt/modals/about.py index e1cf4ee0..c85c501b 100644 --- a/src/tagstudio/qt/modals/about.py +++ b/src/tagstudio/qt/modals/about.py @@ -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"] diff --git a/src/tagstudio/qt/modals/ffmpeg_checker.py b/src/tagstudio/qt/modals/ffmpeg_checker.py index 287f4d3f..c87f538a 100644 --- a/src/tagstudio/qt/modals/ffmpeg_checker.py +++ b/src/tagstudio/qt/modals/ffmpeg_checker.py @@ -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"{Translations["generic.missing"]}" + found = f"{Translations['about.module.found']}" + 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"]}

{status}") diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 14b75afd..da893166 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -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() diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index ed683b61..6829e601 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -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}
{ffprobe}: {ffprobe_status}", "field.copy": "Copy Field", "field.edit": "Edit Field", "field.paste": "Paste Field",