Files
MajoorWaldi-ComfyUI-Majoor-…/backend/tool_detect.py
T
Your Name 1646e2171b feat: Implement duplicate detection and similarity analysis features
- Added DuplicatesService for background duplicate analysis and similarity detection.
- Introduced endpoints for analyzing duplicates, checking status, and merging tags.
- Created utility functions for file hashing and perceptual hashing.
- Implemented watcher settings for runtime tuning of debounce and deduplication.
- Added database maintenance tools for resetting and deleting the database.
- Enhanced error handling with sanitization for client responses.
- Added tests for duplicates service and error handling.
- Updated ESLint configuration for better code quality checks.
- Introduced filename validation and logging utilities.
2026-02-08 15:11:17 +01:00

179 lines
5.3 KiB
Python

"""
Tool detection helpers for ExifTool and FFprobe.
Cached detection to avoid repeated subprocess calls.
"""
import re
import shutil
import subprocess
from typing import Any, Dict, Optional, Tuple
from backend.config import (
EXIFTOOL_BIN,
EXIFTOOL_MIN_VERSION,
FFPROBE_BIN,
FFPROBE_MIN_VERSION,
)
from backend.shared import get_logger
logger = get_logger(__name__)
# Cache tool availability (None = not checked, True/False = result)
_TOOL_CACHE: Dict[str, Optional[bool]] = {
"exiftool": None,
"ffprobe": None,
}
# Cache tool versions
_TOOL_VERSIONS: Dict[str, Optional[str]] = {
"exiftool": None,
"ffprobe": None,
}
def parse_tool_version(value: Optional[str]) -> Tuple[int, ...]:
if not value:
return ()
parts = re.findall(r"\d+", value)
out: list[int] = []
for part in parts:
try:
out.append(int(part))
except ValueError:
continue
return tuple(out)
def version_satisfies_minimum(actual: Optional[str], minimum: str) -> bool:
if not minimum:
return True
minimum_parts = parse_tool_version(minimum)
if not minimum_parts:
return True
actual_parts = parse_tool_version(actual or "")
if not actual_parts:
return False
length = max(len(actual_parts), len(minimum_parts))
padded_actual = list(actual_parts) + [0] * (length - len(actual_parts))
padded_minimum = list(minimum_parts) + [0] * (length - len(minimum_parts))
return tuple(padded_actual) >= tuple(padded_minimum)
def _enforce_min_version(name: str, actual: Optional[str], minimum: str) -> bool:
if not minimum:
return True
if version_satisfies_minimum(actual, minimum):
return True
logger.warning(
"%s version %s does not meet minimum required %s",
name,
actual or "<unknown>",
minimum,
)
return False
def _run_command(cmd: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=2,
check=False,
)
def has_exiftool() -> bool:
"""Check if ExifTool is available."""
if _TOOL_CACHE["exiftool"] is not None:
return _TOOL_CACHE["exiftool"]
try:
if shutil.which(EXIFTOOL_BIN) is None:
logger.debug("ExifTool binary not found in PATH: %s", EXIFTOOL_BIN)
result = _run_command([EXIFTOOL_BIN, "-ver"])
available = result.returncode == 0
_TOOL_CACHE["exiftool"] = available
if available:
version = result.stdout.strip()
_TOOL_VERSIONS["exiftool"] = version
if not _enforce_min_version("ExifTool", version, EXIFTOOL_MIN_VERSION):
_TOOL_CACHE["exiftool"] = False
return False
logger.info("ExifTool detected: version %s", version)
else:
logger.warning("ExifTool not found or failed to start: %s", result.stderr.strip())
return available
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
logger.warning("ExifTool detection failed: %s", exc)
_TOOL_CACHE["exiftool"] = False
return False
except Exception as exc:
logger.warning("ExifTool detection raised unexpected error: %s", exc)
_TOOL_CACHE["exiftool"] = False
return False
def has_ffprobe() -> bool:
"""Check if FFprobe is available."""
if _TOOL_CACHE["ffprobe"] is not None:
return _TOOL_CACHE["ffprobe"]
try:
if shutil.which(FFPROBE_BIN) is None:
logger.debug("FFprobe binary not found in PATH: %s", FFPROBE_BIN)
result = _run_command([FFPROBE_BIN, "-version"])
available = result.returncode == 0
_TOOL_CACHE["ffprobe"] = available
if available:
version_line = result.stdout.split("\n")[0] if result.stdout else ""
version = version_line.strip()
_TOOL_VERSIONS["ffprobe"] = version
if not _enforce_min_version("FFprobe", version, FFPROBE_MIN_VERSION):
_TOOL_CACHE["ffprobe"] = False
return False
logger.info("FFprobe detected: %s", version)
else:
logger.warning("FFprobe not found or failed to start: %s", result.stderr.strip())
return available
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
logger.warning("FFprobe detection failed: %s", exc)
_TOOL_CACHE["ffprobe"] = False
return False
except Exception as exc:
logger.warning("FFprobe detection raised unexpected error: %s", exc)
_TOOL_CACHE["ffprobe"] = False
return False
def get_tool_status() -> Dict[str, Any]:
"""
Get the status of all tools.
Returns: {
"exiftool": bool,
"ffprobe": bool,
"versions": {
"exiftool": str | null,
"ffprobe": str | null
}
}
"""
return {
"exiftool": has_exiftool(),
"ffprobe": has_ffprobe(),
"versions": {
"exiftool": _TOOL_VERSIONS.get("exiftool"),
"ffprobe": _TOOL_VERSIONS.get("ffprobe"),
},
}
def reset_tool_cache():
"""Reset tool detection cache (for testing or manual refresh)."""
global _TOOL_CACHE, _TOOL_VERSIONS
_TOOL_CACHE = {"exiftool": None, "ffprobe": None}
_TOOL_VERSIONS = {"exiftool": None, "ffprobe": None}