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