diff --git a/src/tagstudio/core/utils/ffmpeg_status.py b/src/tagstudio/core/utils/ffmpeg_status.py
new file mode 100644
index 00000000..c8408215
--- /dev/null
+++ b/src/tagstudio/core/utils/ffmpeg_status.py
@@ -0,0 +1,59 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: MIT
+
+
+import contextlib
+from typing import override
+
+import structlog
+
+from tagstudio.core.utils.module_status import ModuleStatus
+from tagstudio.core.utils.silent_subprocess import (
+ silent_run, # pyright: ignore[reportUnknownVariableType]
+)
+
+logger = structlog.get_logger(__name__)
+
+
+class _FfModuleStatus(ModuleStatus):
+ """A base class that implements common logic for reading FFmpeg/FFprobe version output."""
+
+ _FFMPEG = "ffmpeg"
+ _FFPROBE = "ffprobe"
+
+ @classmethod
+ def ff_version(cls, command: str):
+ ff_cmd = cls._which(command)
+ if ff_cmd:
+ out = silent_run([ff_cmd, "-version"], shell=False, capture_output=True, text=True)
+ if out.returncode == 0:
+ with contextlib.suppress(Exception):
+ return str(out.stdout).split(" ")[2]
+
+
+class FfmpegStatus(_FfModuleStatus):
+ """Class for getting the location and version of FFmpeg, if it exists."""
+
+ @override
+ @classmethod
+ def which(cls):
+ return cls._which(cls._FFMPEG)
+
+ @override
+ @classmethod
+ def _version(cls):
+ return cls.ff_version(cls._FFMPEG)
+
+
+class FfprobeStatus(_FfModuleStatus):
+ """Class for getting the location and version of FFprobe, if it exists."""
+
+ @override
+ @classmethod
+ def which(cls):
+ return cls._which(cls._FFPROBE)
+
+ @override
+ @classmethod
+ def _version(cls):
+ return cls.ff_version(cls._FFPROBE)
diff --git a/src/tagstudio/core/utils/module_status.py b/src/tagstudio/core/utils/module_status.py
new file mode 100644
index 00000000..d564dbe1
--- /dev/null
+++ b/src/tagstudio/core/utils/module_status.py
@@ -0,0 +1,75 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: MIT
+
+import os
+import platform
+from shutil import which
+
+import structlog
+
+logger = structlog.get_logger(__name__)
+user = os.environ.get("USER", None)
+
+# NOTE: macOS does not make its PATH variable available to processes started outside the terminal.
+# The following is a list of common directories to search for binaries in.
+# TODO: Use a library for XDG compliance
+_MACOS_BIN_LOCATIONS: list[str] = [
+ "", # Equates to PATH itself
+ # User level
+ "~/.local/share/bin/", # XDG-compliant user-created bin
+ "~/.local/bin/", # Fallback user-created bin
+ "~/.local/state/nix/profile/bin/", # XDG-compliant home Nix bin
+ "~/.nix-profile/bin/", # Fallback home Nix bin
+ # System level
+ f"/etc/profiles/per-user/{user}/bin/", # Per-user Nix bin
+ "/nix/var/nix/profiles/default/bin/", # Inherited Nix bin
+ "/opt/homebrew/bin/", # Homebrew bin
+ "/usr/local/bin/", # Administrator-configured bin
+ "/usr/bin/", # System bin
+ "/bin/", # Core system bin
+]
+
+
+class ModuleStatus:
+ """An abstract base class for module status logic including the binary location and version."""
+
+ __cached_location: str | None = None
+ __cached_version: str | None = None
+
+ @classmethod
+ def which(cls) -> str | None:
+ raise NotImplementedError()
+
+ @classmethod
+ def _cache_location(cls) -> str | None:
+ raise NotImplementedError()
+
+ @classmethod
+ def version(cls) -> str | None:
+ if cls.__cached_version is None:
+ cls.__cached_version = cls._version()
+ return cls.__cached_version
+
+ @classmethod
+ def _version(cls) -> str | None:
+ raise NotImplementedError()
+
+ @classmethod
+ def _which(cls, cmd: str) -> str | None:
+ """Internal method for determining the correct location for which().
+
+ Args:
+ cmd (str): The process command to search for.
+ """
+ if cls.__cached_location:
+ return cls.__cached_location
+
+ if platform.system() == "Darwin":
+ for loc in _MACOS_BIN_LOCATIONS:
+ full_command = which(loc + cmd)
+ if full_command:
+ cls.__cached_location = full_command
+ return full_command
+
+ cls.__cached_location = which(cmd)
+ return cls.__cached_location
diff --git a/src/tagstudio/core/utils/ripgrep_status.py b/src/tagstudio/core/utils/ripgrep_status.py
new file mode 100644
index 00000000..d6e5e6c8
--- /dev/null
+++ b/src/tagstudio/core/utils/ripgrep_status.py
@@ -0,0 +1,34 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: MIT
+
+
+import contextlib
+from typing import override
+
+import structlog
+
+from tagstudio.core.utils.module_status import ModuleStatus
+from tagstudio.core.utils.silent_subprocess import (
+ silent_run, # pyright: ignore[reportUnknownVariableType]
+)
+
+logger = structlog.get_logger(__name__)
+
+
+class RipgrepStatus(ModuleStatus):
+ """Class for getting the location and version of ripgrep, if it exists."""
+
+ @override
+ @classmethod
+ def which(cls):
+ return cls._which("rg")
+
+ @override
+ @classmethod
+ def _version(cls):
+ ripgrep_cmd = cls.which()
+ if ripgrep_cmd:
+ out = silent_run([ripgrep_cmd, "-V"], shell=False, capture_output=True, text=True)
+ if out.returncode == 0:
+ with contextlib.suppress(Exception):
+ return str(out.stdout).split(" ")[1].rstrip("\n")
diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py
index d1150d04..5b868bec 100644
--- a/src/tagstudio/qt/controllers/preview_panel_controller.py
+++ b/src/tagstudio/qt/controllers/preview_panel_controller.py
@@ -3,14 +3,13 @@
import typing
-from shutil import which
from warnings import catch_warnings
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
+from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
-from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
@@ -59,7 +58,7 @@ class PreviewPanel(PreviewPanelView):
self._containers.update_from_entry(self._selected[0])
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
- if enable_warning and (not which(FFMPEG_CMD) or not which(FFPROBE_CMD)):
+ if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()):
self._ffmpeg_warning_widget.show()
return
diff --git a/src/tagstudio/qt/helpers/file_tester.py b/src/tagstudio/qt/helpers/file_tester.py
index 89e20295..b9c4305a 100644
--- a/src/tagstudio/qt/helpers/file_tester.py
+++ b/src/tagstudio/qt/helpers/file_tester.py
@@ -6,9 +6,7 @@ from pathlib import Path
import ffmpeg
-from tagstudio.qt.previews.vendored.ffmpeg import (
- probe, # pyright: ignore[reportUnknownVariableType]
-)
+from tagstudio.qt.previews.vendored.probe import probe
def is_readable_video(filepath: Path | str):
@@ -21,6 +19,8 @@ def is_readable_video(filepath: Path | str):
"""
try:
result = probe(Path(filepath))
+ if not result:
+ return False
for stream in result["streams"]:
# DRM check
if stream.get("codec_tag_string") in [
diff --git a/src/tagstudio/qt/mixed/about_modal.py b/src/tagstudio/qt/mixed/about_modal.py
index a46d354d..e9b52297 100644
--- a/src/tagstudio/qt/mixed/about_modal.py
+++ b/src/tagstudio/qt/mixed/about_modal.py
@@ -4,7 +4,6 @@
import math
from pathlib import Path
-from shutil import which
from PIL import ImageQt
from PySide6.QtCore import QSize, Qt
@@ -28,15 +27,16 @@ from tagstudio.core.constants import (
VERSION_BRANCH,
)
from tagstudio.core.ts_core import TagStudioCore
+from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
+from tagstudio.core.utils.ripgrep_status import RipgrepStatus
from tagstudio.core.utils.str_formatting import is_version_outdated
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
-from tagstudio.qt.previews.vendored import ffmpeg
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.utils.file_opener import open_file
-from tagstudio.qt.views.stylesheets.stylesheets import form_content_style
+from tagstudio.qt.views.stylesheets.stylesheets import form_content_style, header
class AboutModal(QWidget):
@@ -66,6 +66,10 @@ class AboutModal(QWidget):
self.content_layout.setContentsMargins(12, 12, 12, 12)
self.content_layout.setSpacing(12)
+ red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
+ green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
+ amber = get_ui_color(ColorType.PRIMARY, UiColor.AMBER)
+
# TagStudio Logo -------------------------------------------------------
self.logo_widget = QLabel()
self.logo_pixmap = QPixmap.fromImage(ImageQt.ImageQt(self.rm.ts_logo_text_color))
@@ -78,7 +82,7 @@ class AboutModal(QWidget):
self.logo_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Version --------------------------------------------------------------
- self.version_label = QLabel(f"
{AboutModal.VERSION_STR}
")
+ self.version_label = QLabel(header(AboutModal.VERSION_STR, 3))
self.version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Copyright ------------------------------------------------------------
@@ -94,31 +98,10 @@ class AboutModal(QWidget):
self.desc_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# System Info ----------------------------------------------------------
- ff_version = ffmpeg.version()
- red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
- green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
- amber = get_ui_color(ColorType.PRIMARY, UiColor.AMBER)
- missing = Translations["generic.missing"]
- found = Translations["about.module.found"]
-
- ffmpeg_status = f'{missing}'
- if ff_version["ffmpeg"] is not None:
- ffmpeg_status = (
- f'{found} (' + ff_version["ffmpeg"] + ")"
- )
-
- ffprobe_status = f'{missing}'
- if ff_version["ffprobe"] is not None:
- ffprobe_status = (
- f'{found} (' + ff_version["ffprobe"] + ")"
- )
-
- ripgrep_status = f'{missing}'
- if which("rg") is not None:
- ripgrep_status = f'{found}'
self.system_info_widget = QWidget()
self.system_info_layout = QFormLayout(self.system_info_widget)
+ self.system_info_layout.setSpacing(4)
self.system_info_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
# Version
@@ -149,46 +132,85 @@ class AboutModal(QWidget):
# TODO: Add row for "App Cache Path" (currently that TagStudio.ini file)
+ # Optional Modules -----------------------------------------------------
+
+ self.parent_optional_modules_widget = QWidget()
+ self.parent_optional_modules_layout = QVBoxLayout(self.parent_optional_modules_widget)
+ self.parent_optional_modules_layout.setContentsMargins(0, 0, 0, 0)
+ self.parent_optional_modules_layout.setSpacing(0)
+
+ # Subtitle
+ self.optional_modules_label = QLabel(header(Translations["about.modules.title"], 4))
+
+ self.optional_modules_widget = QWidget()
+ self.optional_modules_layout = QFormLayout(self.optional_modules_widget)
+ self.optional_modules_layout.setSpacing(4)
+ self.optional_modules_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
+
+ ffmpeg_ver = FfmpegStatus.version()
+ ffprobe_ver = FfprobeStatus.version()
+ ripgrep_ver = RipgrepStatus.version()
+
+ missing = Translations["generic.missing"]
+ found = Translations["about.module.found"]
+
+ ffmpeg_status = f'{missing}'
+ if ffmpeg_ver is not None:
+ ffmpeg_status = f'{found} (' + ffmpeg_ver + ")"
+
+ ffprobe_status = f'{missing}'
+ if ffprobe_ver is not None:
+ ffprobe_status = f'{found} (' + ffprobe_ver + ")"
+
+ ripgrep_status = f'{missing}'
+ if ripgrep_ver is not None:
+ ripgrep_status = f'{found} (' + ripgrep_ver + ")"
+
# FFmpeg Status
ffmpeg_path_title = QLabel("FFmpeg")
ffmpeg_path_content = ClickableLabel(f"{ffmpeg_status}")
- ffmpeg_location = which(ffmpeg._get_ffmpeg_location()) # pyright: ignore[reportPrivateUsage]
+ ffmpeg_location = FfmpegStatus.which()
if ffmpeg_location:
ffmpeg_path_content.clicked.connect(
lambda: open_file(ffmpeg_location, file_manager=True)
)
ffmpeg_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
+ ffmpeg_path_content.setToolTip(ffmpeg_location)
ffmpeg_path_content.setStyleSheet(form_content_style())
- self.system_info_layout.addRow(ffmpeg_path_title, ffmpeg_path_content)
+ self.optional_modules_layout.addRow(ffmpeg_path_title, ffmpeg_path_content)
ffmpeg_path_content.setMaximumWidth(ffmpeg_path_content.sizeHint().width())
# FFprobe Status
ffprobe_path_title = QLabel("FFprobe")
ffprobe_path_content = ClickableLabel(f"{ffprobe_status}")
- ffprobe_location = which(ffmpeg._get_ffprobe_location()) # pyright: ignore[reportPrivateUsage]
+ ffprobe_location = FfprobeStatus.which()
if ffprobe_location:
ffprobe_path_content.clicked.connect(
lambda: open_file(ffprobe_location, file_manager=True)
)
ffprobe_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
+ ffprobe_path_content.setToolTip(ffprobe_location)
ffprobe_path_content.setStyleSheet(form_content_style())
- self.system_info_layout.addRow(ffprobe_path_title, ffprobe_path_content)
+ self.optional_modules_layout.addRow(ffprobe_path_title, ffprobe_path_content)
ffprobe_path_content.setMaximumWidth(ffprobe_path_content.sizeHint().width())
# ripgrep Status
- # TODO: Add a central class to find ripgrep info, similar to ffmpeg
ripgrep_path_title = QLabel("ripgrep") # NOTE: Don't localize
ripgrep_path_content = ClickableLabel()
ripgrep_path_content.setText(f"{ripgrep_status}") # TODO: Pass in constructor after #1386
- ripgrep_location = which("rg")
+ ripgrep_location = RipgrepStatus.which()
if ripgrep_location:
ripgrep_path_content.clicked.connect(
lambda: open_file(ripgrep_location, file_manager=True)
)
ripgrep_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
+ ripgrep_path_content.setToolTip(ripgrep_location)
ripgrep_path_content.setStyleSheet(form_content_style())
ripgrep_path_content.setMaximumWidth(ripgrep_path_content.sizeHint().width())
- self.system_info_layout.addRow(ripgrep_path_title, ripgrep_path_content)
+ self.optional_modules_layout.addRow(ripgrep_path_title, ripgrep_path_content)
+
+ self.parent_optional_modules_layout.addWidget(self.optional_modules_label)
+ self.parent_optional_modules_layout.addWidget(self.optional_modules_widget)
# Links ----------------------------------------------------------------
@@ -217,6 +239,7 @@ class AboutModal(QWidget):
self.content_layout.addWidget(self.version_label)
self.content_layout.addWidget(self.desc_label)
self.content_layout.addWidget(self.system_info_widget)
+ self.content_layout.addWidget(self.parent_optional_modules_widget)
self.content_layout.addStretch(1)
self.content_layout.addWidget(self.links_label)
self.content_layout.addWidget(self.copyright_label)
diff --git a/src/tagstudio/qt/previews/vendored/ffmpeg.py b/src/tagstudio/qt/previews/vendored/ffmpeg.py
deleted file mode 100644
index e3b3d57b..00000000
--- a/src/tagstudio/qt/previews/vendored/ffmpeg.py
+++ /dev/null
@@ -1,102 +0,0 @@
-# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
-# SPDX-FileCopyrightText: (c) TagStudio Contributors
-# SPDX-License-Identifier: GPL-3.0-only
-# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
-
-
-import contextlib
-import json
-import os
-import platform
-import subprocess
-from shutil import which
-
-import ffmpeg
-import structlog
-
-from tagstudio.core.utils.silent_subprocess import silent_popen, silent_run
-
-logger = structlog.get_logger(__name__)
-
-user = os.environ.get("USER", None)
-FFMPEG_MACOS_LOCATIONS: list[str] = [
- "",
- "/opt/homebrew/bin/",
- "/usr/local/bin/",
- f"/etc/profiles/per-user/{user}/bin",
-]
-
-
-# TODO: Make this more intuitive to use in other classes
-def _get_ffprobe_location() -> str:
- cmd: str = "ffprobe"
- if platform.system() == "Darwin":
- for loc in FFMPEG_MACOS_LOCATIONS:
- if which(loc + cmd):
- cmd = loc + cmd
- break
- logger.info(
- f"[FFmpeg] Using FFprobe location: {cmd}{' (Found)' if which(cmd) else ' (Not Found)'}"
- )
- return cmd
-
-
-# TODO: Make this more intuitive to use in other classes
-def _get_ffmpeg_location() -> str:
- cmd: str = "ffmpeg"
- if platform.system() == "Darwin":
- for loc in FFMPEG_MACOS_LOCATIONS:
- if which(loc + cmd):
- cmd = loc + cmd
- break
- logger.info(
- f"[FFmpeg] Using FFmpeg location: {cmd}{' (Found)' if which(cmd) else ' (Not Found)'}"
- )
- return cmd
-
-
-FFPROBE_CMD = _get_ffprobe_location()
-FFMPEG_CMD = _get_ffmpeg_location()
-
-
-def probe(filename, cmd=FFPROBE_CMD, timeout=None, **kwargs):
- """Run ffprobe on the specified file and return a JSON representation of the output.
-
- Raises:
- :class:`ffmpeg.Error`: if ffprobe returns a non-zero exit code,
- an :class:`Error` is returned with a generic error message.
- The stderr output can be retrieved by accessing the
- ``stderr`` property of the exception.
- """
- args = [cmd, "-show_format", "-show_streams", "-of", "json"]
- args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs) # pyright: ignore[reportAttributeAccessIssue]
- args += [filename]
-
- # PATCHED
- p = silent_popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- communicate_kwargs = {}
- if timeout is not None:
- communicate_kwargs["timeout"] = timeout
- out, err = p.communicate(**communicate_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/previews/vendored/probe.py b/src/tagstudio/qt/previews/vendored/probe.py
new file mode 100644
index 00000000..450a3d28
--- /dev/null
+++ b/src/tagstudio/qt/previews/vendored/probe.py
@@ -0,0 +1,45 @@
+# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
+
+
+import json
+import subprocess
+from pathlib import Path
+
+import ffmpeg
+import structlog
+
+from tagstudio.core.utils.ffmpeg_status import FfprobeStatus
+from tagstudio.core.utils.silent_subprocess import (
+ silent_popen, # pyright: ignore[reportUnknownVariableType]
+)
+
+logger = structlog.get_logger(__name__)
+
+
+def probe(filename: Path | str, timeout: int | None = None, **kwargs: ...):
+ """Run ffprobe on the specified file and return a JSON representation of the output.
+
+ Raises:
+ Error: If ffprobe returns a non-zero exit code, an Error is raised
+ with a generic error message. The stderr output can be retrieved
+ by accessing the `stderr` property of the exception.
+ """
+ ffprobe_cmd: str | None = FfprobeStatus.which()
+ if not ffprobe_cmd:
+ return
+ args: list[str] = [ffprobe_cmd, "-show_format", "-show_streams", "-of", "json"]
+ args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs) # pyright: ignore
+ args += [filename] # pyright: ignore
+
+ # PATCHED
+ p = silent_popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ communicate_kwargs = {}
+ if timeout is not None:
+ communicate_kwargs["timeout"] = timeout
+ out, err = p.communicate(**communicate_kwargs)
+ if p.returncode != 0:
+ raise ffmpeg.Error("ffprobe", out, err)
+ return json.loads(out.decode("utf-8"))
diff --git a/src/tagstudio/qt/previews/vendored/pydub/audio_segment.py b/src/tagstudio/qt/previews/vendored/pydub/audio_segment.py
index e3c05233..e186a656 100644
--- a/src/tagstudio/qt/previews/vendored/pydub/audio_segment.py
+++ b/src/tagstudio/qt/previews/vendored/pydub/audio_segment.py
@@ -18,7 +18,7 @@ from tempfile import NamedTemporaryFile
from pydub.logging_utils import log_conversion, log_subprocess_output
from pydub.utils import fsdecode
-from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD
+from tagstudio.core.utils.ffmpeg_status import FfmpegStatus
try:
from itertools import izip
@@ -161,7 +161,7 @@ class _AudioSegment:
slice = a[5000:10000] # get a slice from 5 to 10 seconds of an mp3
"""
- converter = FFMPEG_CMD
+ converter = FfmpegStatus.which()
# TODO: remove in 1.0 release
# maintain backwards compatibility for ffmpeg attr (now called converter)
@@ -727,7 +727,7 @@ class _AudioSegment:
stdin_parameter = None
stdin_data = None
else:
- if cls.converter == FFMPEG_CMD:
+ if cls.converter == FfmpegStatus.which():
conversion_command += [
"-read_ahead_limit",
str(read_ahead_limit),
diff --git a/src/tagstudio/qt/previews/vendored/pydub/utils.py b/src/tagstudio/qt/previews/vendored/pydub/utils.py
index abc8ce9f..d5ba1356 100644
--- a/src/tagstudio/qt/previews/vendored/pydub/utils.py
+++ b/src/tagstudio/qt/previews/vendored/pydub/utils.py
@@ -14,13 +14,13 @@ from pydub.utils import (
get_extra_info,
)
+from tagstudio.core.utils.ffmpeg_status import FfprobeStatus
from tagstudio.core.utils.silent_subprocess import silent_popen
-from tagstudio.qt.previews.vendored.ffmpeg import FFPROBE_CMD
def _mediainfo_json(filepath, read_ahead_limit=-1):
"""Return json dictionary with media info(codec, duration, size, bitrate...) from filepath."""
- prober = FFPROBE_CMD
+ prober = FfprobeStatus.which()
command_args = [
"-v",
"info",
diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py
index 5a40b3c1..f18ff1ac 100644
--- a/src/tagstudio/qt/ts_qt.py
+++ b/src/tagstudio/qt/ts_qt.py
@@ -39,7 +39,6 @@ from PySide6.QtGui import (
)
from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox, QPushButton, QScrollArea
-# This import has side-effect of importing PySide resources
import tagstudio.qt.resources_rc # noqa: F401 # pyright: ignore[reportUnusedImport]
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
from tagstudio.core.driver import DriverMixin
@@ -52,6 +51,11 @@ from tagstudio.core.library.refresh import RefreshTracker
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.query_lang.util import ParsingError
from tagstudio.core.ts_core import TagStudioCore
+
+# This import has side-effect of importing PySide resources
+from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
+from tagstudio.core.utils.module_status import ModuleStatus
+from tagstudio.core.utils.ripgrep_status import RipgrepStatus
from tagstudio.core.utils.str_formatting import is_version_outdated
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.cache_manager import CacheManager
@@ -320,6 +324,9 @@ class QtDriver(DriverMixin, QObject):
timer.start(500)
timer.timeout.connect(lambda: None)
+ # Detect optional modules and versions for logging
+ self.log_optional_modules()
+
# self.main_window = loader.load(home_path)
self.main_window = MainWindow(self)
self.main_window.setWindowTitle(self.base_title)
@@ -1755,3 +1762,17 @@ class QtDriver(DriverMixin, QObject):
def clear_selected(self):
self._selected.clear()
self.main_window.thumb_layout.update_selected()
+
+ def log_optional_modules(self) -> None:
+ """Logs the status of optional modules."""
+ status_classes: list[tuple[str, type[ModuleStatus]]] = [
+ ("FFmpeg", FfmpegStatus),
+ ("FFprobe", FfprobeStatus),
+ ("ripgrep", RipgrepStatus),
+ ]
+
+ for name, sc in status_classes:
+ if sc.which():
+ logger.info(f"[QtDriver] {name} found", which=sc.which(), version=sc.version())
+ else:
+ logger.warning(f"[QtDriver] {sc} not found")
diff --git a/src/tagstudio/qt/views/stylesheets/stylesheets.py b/src/tagstudio/qt/views/stylesheets/stylesheets.py
index 162e1868..6a9e722b 100644
--- a/src/tagstudio/qt/views/stylesheets/stylesheets.py
+++ b/src/tagstudio/qt/views/stylesheets/stylesheets.py
@@ -205,14 +205,16 @@ def container_style() -> str:
def form_content_style() -> str:
return f"""
- background-color: {
+ QLabel{{
+ background-color: {
Theme.COLOR_BG.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
};
- border-radius: 3px;
- font-weight: 500;
- padding: 1px;
+ border-radius: 3px;
+ font-weight: 500;
+ padding: 1px;
+ }}
"""
diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json
index 84fd2741..0742b93d 100644
--- a/src/tagstudio/resources/translations/en.json
+++ b/src/tagstudio/resources/translations/en.json
@@ -4,6 +4,7 @@
"about.description": "TagStudio is a photo and file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.",
"about.documentation": "Documentation",
"about.module.found": "Found",
+ "about.modules.title": "Optional Modules",
"about.title": "About TagStudio",
"about.version": "Version",
"about.version.latest": "{built_version} (Latest Release: {latest_version})",
diff --git a/tests/qt/test_about_modal.py b/tests/qt/test_about_modal.py
index 7ee64735..ccf0e03f 100644
--- a/tests/qt/test_about_modal.py
+++ b/tests/qt/test_about_modal.py
@@ -11,7 +11,7 @@ def test_github_api_unavailable(qtbot: QtBot, mocker) -> None:
mocker.patch(
"requests.get",
side_effect=ConnectionError(
- "Failed to resolve 'api.github.com' ([Errno -3] Temporary failure in name resolution)"
+ "Emulating a failure with 'api.github.com' ([Errno 0] This should be handled)"
),
)
modal = AboutModal("/tmp")