diff --git a/tagstudio/resources/translations/en.json b/tagstudio/resources/translations/en.json index 988fb003..0f96a4c4 100644 --- a/tagstudio/resources/translations/en.json +++ b/tagstudio/resources/translations/en.json @@ -66,6 +66,8 @@ "folders_to_tags.description": "Creates tags based on your folder structure and applies them to your entries.\n The structure below shows all the tags that will be created and what entries they will be applied to.", "folders_to_tags.open_all": "Open All", "folders_to_tags.title": "Create Tags From Folders", + "about.title": "About", + "about.content": "

TagStudio Alpha {version} ({branch})

TagStudio is a photo & 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.

License: GPLv3
Config path: {config_path}
FFmpeg: {ffmpeg}
FFprobe: {ffprobe}

GitHub | Documentation | Discord

", "generic.add": "Add", "generic.apply_alt": "&Apply", "generic.apply": "Apply", @@ -166,6 +168,7 @@ "menu.file.save_library": "Save Library", "menu.file": "&File", "menu.help": "&Help", + "menu.help.about": "About", "menu.macros.folders_to_tags": "Folders to Tags", "menu.macros": "&Macros", "menu.select": "Select", diff --git a/tagstudio/src/qt/modals/about.py b/tagstudio/src/qt/modals/about.py new file mode 100644 index 00000000..ec1f8146 --- /dev/null +++ b/tagstudio/src/qt/modals/about.py @@ -0,0 +1,79 @@ +# Copyright (C) 2025 +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +from PIL import ImageQt +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, + QWidget, +) +from src.core.constants import VERSION, VERSION_BRANCH +from src.qt.modals.ffmpeg_checker import FfmpegChecker +from src.qt.resource_manager import ResourceManager +from src.qt.translations import Translations + + +class AboutModal(QWidget): + def __init__(self, config_path): + super().__init__() + Translations.translate_with_setter(self.setWindowTitle, "about.title") + + self.fc: FfmpegChecker = FfmpegChecker() + self.rm: ResourceManager = ResourceManager() + + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setMinimumSize(400, 500) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(20, 20, 20, 6) + + self.logo_widget = QLabel() + self.logo_widget.setObjectName("logo") + self.logo_pixmap = QPixmap.fromImage(ImageQt.ImageQt(self.rm.get("logo"))) + self.logo_pixmap = self.logo_pixmap.scaledToWidth( + 128, Qt.TransformationMode.SmoothTransformation + ) + self.logo_widget.setPixmap(self.logo_pixmap) + self.logo_widget.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.logo_widget.setContentsMargins(0, 0, 0, 20) + + self.content_widget = QLabel() + self.content_widget.setObjectName("contentLabel") + self.content_widget.setWordWrap(True) + ff_version = self.fc.version() + ffmpeg = 'Missing' + if ff_version["ffmpeg"] is not None: + ffmpeg = 'Found (' + ff_version["ffmpeg"] + ")" + ffprobe = 'Missing' + if ff_version["ffprobe"] is not None: + ffprobe = 'Found (' + ff_version["ffprobe"] + ")" + Translations.translate_qobject( + self.content_widget, + "about.content", + version=VERSION, + branch=VERSION_BRANCH, + config_path=config_path, + ffmpeg=ffmpeg, + ffprobe=ffprobe, + ) + self.content_widget.setAlignment(Qt.AlignmentFlag.AlignHCenter) + + self.button_widget = QWidget() + self.button_layout = QHBoxLayout(self.button_widget) + self.button_layout.addStretch(1) + + self.close_button = QPushButton() + Translations.translate_qobject(self.close_button, "generic.close") + self.close_button.clicked.connect(lambda: self.close()) + self.close_button.setMaximumWidth(80) + + self.button_layout.addWidget(self.close_button) + + self.root_layout.addWidget(self.logo_widget) + self.root_layout.addWidget(self.content_widget, Qt.AlignmentFlag.AlignTop) + self.root_layout.addWidget(self.button_widget) diff --git a/tagstudio/src/qt/modals/ffmpeg_checker.py b/tagstudio/src/qt/modals/ffmpeg_checker.py new file mode 100644 index 00000000..58dcac98 --- /dev/null +++ b/tagstudio/src/qt/modals/ffmpeg_checker.py @@ -0,0 +1,84 @@ +import contextlib +import subprocess +from shutil import which + +import structlog +from PySide6.QtCore import Qt, QUrl +from PySide6.QtGui import QDesktopServices +from PySide6.QtWidgets import QMessageBox +from src.qt.helpers.vendored.ffmpeg import FFPROBE_CMD + +logger = structlog.get_logger(__name__) + + +class FfmpegChecker(QMessageBox): + """A warning dialog for if FFmpeg is missing.""" + + HELP_URL = "https://docs.tagstud.io/help/ffmpeg/" + + def __init__(self): + super().__init__() + + self.setWindowTitle("Warning: Missing dependency") + self.setText("Warning: Could not find FFmpeg installation") + self.setIcon(QMessageBox.Icon.Warning) + # Blocks other application interactions until resolved + self.setWindowModality(Qt.WindowModality.ApplicationModal) + + self.setStandardButtons( + QMessageBox.StandardButton.Help + | QMessageBox.StandardButton.Ignore + | QMessageBox.StandardButton.Cancel + ) + 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.ffmpeg = False + self.ffprobe = False + + def installed(self): + """Checks if both FFmpeg and FFprobe are installed and in the PATH.""" + if which("ffmpeg"): + 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", "-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 respone.""" + 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)) diff --git a/tagstudio/src/qt/resources.json b/tagstudio/src/qt/resources.json index e5857909..cfec199b 100644 --- a/tagstudio/src/qt/resources.json +++ b/tagstudio/src/qt/resources.json @@ -1,4 +1,8 @@ { + "logo": { + "path": "icon.png", + "mode": "pil" + }, "play_icon": { "path": "qt/images/play.svg", "mode": "rb" diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index b6e36ae4..3dfb82a2 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -14,7 +14,6 @@ import os import re import sys import time -import webbrowser from pathlib import Path from queue import Queue @@ -75,8 +74,10 @@ from src.qt.flowlayout import FlowLayout from src.qt.helpers.custom_runnable import CustomRunnable from src.qt.helpers.function_iterator import FunctionIterator from src.qt.main_window import Ui_MainWindow +from src.qt.modals.about import AboutModal from src.qt.modals.build_tag import BuildTagPanel from src.qt.modals.drop_import import DropImportModal +from src.qt.modals.ffmpeg_checker import FfmpegChecker from src.qt.modals.file_extension import FileExtensionModal from src.qt.modals.fix_dupes import FixDupeFilesModal from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal @@ -160,12 +161,14 @@ class QtDriver(DriverMixin, QObject): self.SIGTERM.connect(self.handle_sigterm) + self.config_path = "" if self.args.config_file: path = Path(self.args.config_file) if not path.exists(): logger.warning("Config File does not exist creating", path=path) logger.info("Using Config File", path=path) self.settings = QSettings(str(path), QSettings.Format.IniFormat) + self.config_path = str(path) else: self.settings = QSettings( QSettings.Format.IniFormat, @@ -177,6 +180,7 @@ class QtDriver(DriverMixin, QObject): "Config File not specified, using default one", filename=self.settings.fileName(), ) + self.config_path = self.settings.fileName() def init_workers(self): """Init workers for rendering thumbnails.""" @@ -475,12 +479,15 @@ class QtDriver(DriverMixin, QObject): macros_menu.addAction(folders_to_tags_action) # Help Menu ============================================================ - self.repo_action = QAction(menu_bar) - Translations.translate_qobject(self.repo_action, "help.visit_github") - self.repo_action.triggered.connect( - lambda: webbrowser.open("https://github.com/TagStudioDev/TagStudio") - ) - help_menu.addAction(self.repo_action) + def create_about_modal(): + if not hasattr(self, "about_modal"): + self.about_modal = AboutModal(self.config_path) + self.about_modal.show() + + self.about_action = QAction(menu_bar) + Translations.translate_qobject(self.about_action, "menu.help.about") + self.about_action.triggered.connect(create_about_modal) + help_menu.addAction(self.about_action) self.set_macro_menu_viability() menu_bar.addMenu(file_menu) @@ -537,6 +544,11 @@ class QtDriver(DriverMixin, QObject): ) self.open_library(path_result.library_path) + # check ffmpeg and show warning if not + self.ffmpeg_checker = FfmpegChecker() + if not self.ffmpeg_checker.installed(): + self.ffmpeg_checker.show_warning() + app.exec() self.shutdown()