Skip to content

fix: stop ffmpeg cmd windows, refactor ffmpeg_checker #855

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/tagstudio/qt/helpers/file_tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

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):
Expand All @@ -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",
Expand Down
76 changes: 76 additions & 0 deletions src/tagstudio/qt/helpers/silent_popen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
38 changes: 31 additions & 7 deletions src/tagstudio/qt/helpers/vendored/ffmpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -21,29 +22,33 @@ 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


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


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:
Expand All @@ -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
5 changes: 2 additions & 3 deletions src/tagstudio/qt/modals/about.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
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

Expand All @@ -31,7 +31,6 @@ def __init__(self, config_path):
super().__init__()
self.setWindowTitle(Translations["about.title"])

self.fc: FfmpegChecker = FfmpegChecker()
self.rm: ResourceManager = ResourceManager()

# TODO: There should be a global button theme somewhere.
Expand Down Expand Up @@ -80,7 +79,7 @@ def __init__(self, config_path):
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"]
Expand Down
74 changes: 21 additions & 53 deletions src/tagstudio/qt/modals/ffmpeg_checker.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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 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__)

Expand All @@ -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(
Expand All @@ -34,52 +35,19 @@ def __init__(self):
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"<span style='color:{red}'>{Translations["generic.missing"]}</span>"
found = f"<span style='color:{green}'>{Translations['about.module.found']}</span>"
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"]}<br><br>{status}")
10 changes: 5 additions & 5 deletions src/tagstudio/qt/ts_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import time
from pathlib import Path
from queue import Queue
from shutil import which
from warnings import catch_warnings

import structlog
Expand Down Expand Up @@ -75,6 +76,7 @@
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
Expand Down Expand Up @@ -668,11 +670,9 @@ def create_about_modal():
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()
Expand Down
3 changes: 3 additions & 0 deletions src/tagstudio/resources/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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}<br>{ffprobe}: {ffprobe_status}",
"field.copy": "Copy Field",
"field.edit": "Edit Field",
"field.paste": "Paste Field",
Expand Down