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",