mirror of
https://github.com/blw1138/Zordon.git
synced 2026-02-05 05:36:09 +00:00
* Change uses of os.path to use Pathlib * Add return types and type hints * Add more docstrings * Add missing import to api_server
394 lines
14 KiB
Python
394 lines
14 KiB
Python
import logging
|
|
import json
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import string
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime
|
|
from typing import Optional, List, Dict, Any
|
|
|
|
logger = logging.getLogger()
|
|
|
|
|
|
def launch_url(url: str) -> None:
|
|
logger = logging.getLogger(__name__)
|
|
|
|
if shutil.which('xdg-open'):
|
|
opener = 'xdg-open'
|
|
elif shutil.which('open'):
|
|
opener = 'open'
|
|
elif shutil.which('cmd'):
|
|
opener = 'start'
|
|
else:
|
|
error_message = f"No valid launchers found to launch URL: {url}"
|
|
logger.error(error_message)
|
|
raise OSError(error_message)
|
|
|
|
try:
|
|
if opener == 'start':
|
|
# For Windows, use 'cmd /c start'
|
|
subprocess.run(['cmd', '/c', 'start', url], shell=False)
|
|
else:
|
|
subprocess.run([opener, url])
|
|
except Exception as e:
|
|
logger.error(f"Failed to launch URL: {url}. Error: {e}")
|
|
|
|
|
|
def file_exists_in_mounts(filepath: str) -> Optional[str]:
|
|
"""
|
|
Check if a file exists in any mounted directory.
|
|
It searches for the file in common mount points like '/Volumes', '/mnt', and '/media'.
|
|
Returns the path to the file in the mount if found, otherwise returns None.
|
|
|
|
Example:
|
|
Before: filepath = '/path/to/file.txt'
|
|
After: '/Volumes/ExternalDrive/path/to/file.txt'
|
|
"""
|
|
|
|
def get_path_components(path):
|
|
path = os.path.normpath(path)
|
|
components = []
|
|
while True:
|
|
path, comp = os.path.split(path)
|
|
if comp:
|
|
components.append(comp)
|
|
else:
|
|
if path:
|
|
components.append(path)
|
|
break
|
|
components.reverse()
|
|
return components
|
|
|
|
# Get path components of the directory of the file
|
|
path_components = get_path_components(os.path.dirname(filepath).strip('/'))
|
|
|
|
# Iterate over possible root paths - this may need to be rethought for Windows support
|
|
for root in ['/Volumes', '/mnt', '/media']:
|
|
if os.path.exists(root):
|
|
# Iterate over mounts in the root path
|
|
for mount in os.listdir(root):
|
|
# Since we don't know the home directory, we iterate until we find matching parts of the path
|
|
matching_components = [s for s in path_components if s in mount]
|
|
for component in matching_components:
|
|
possible_mount_path = os.path.join(root, mount, filepath.split(component)[-1].lstrip('/'))
|
|
if os.path.exists(possible_mount_path):
|
|
return possible_mount_path
|
|
|
|
|
|
def get_time_elapsed(start_time: Optional[datetime] = None, end_time: Optional[datetime] = None) -> str:
|
|
|
|
def strfdelta(tdelta, fmt='%H:%M:%S'):
|
|
days = tdelta.days
|
|
hours, rem = divmod(tdelta.seconds, 3600)
|
|
minutes, seconds = divmod(rem, 60)
|
|
|
|
# Using f-strings for formatting
|
|
formatted_str = fmt.replace('%D', f'{days}')
|
|
formatted_str = formatted_str.replace('%H', f'{hours:02d}')
|
|
formatted_str = formatted_str.replace('%M', f'{minutes:02d}')
|
|
formatted_str = formatted_str.replace('%S', f'{seconds:02d}')
|
|
return formatted_str
|
|
|
|
# calculate elapsed time
|
|
elapsed_time = None
|
|
|
|
if start_time:
|
|
if end_time:
|
|
elapsed_time = end_time - start_time
|
|
else:
|
|
elapsed_time = datetime.now() - start_time
|
|
|
|
elapsed_time_string = strfdelta(elapsed_time) if elapsed_time else None
|
|
return elapsed_time_string
|
|
|
|
|
|
def get_file_size_human(file_path: str) -> str:
|
|
size_in_bytes = os.path.getsize(file_path)
|
|
|
|
# Convert size to a human-readable format
|
|
if size_in_bytes < 1024:
|
|
return f"{size_in_bytes} B"
|
|
elif size_in_bytes < 1024 ** 2:
|
|
return f"{size_in_bytes / 1024:.2f} KB"
|
|
elif size_in_bytes < 1024 ** 3:
|
|
return f"{size_in_bytes / 1024 ** 2:.2f} MB"
|
|
elif size_in_bytes < 1024 ** 4:
|
|
return f"{size_in_bytes / 1024 ** 3:.2f} GB"
|
|
else:
|
|
return f"{size_in_bytes / 1024 ** 4:.2f} TB"
|
|
|
|
|
|
def current_system_os() -> str:
|
|
return platform.system().lower().replace('darwin', 'macos')
|
|
|
|
|
|
def current_system_os_version() -> str:
|
|
return platform.release()
|
|
|
|
|
|
def current_system_cpu() -> str:
|
|
return platform.machine().lower().replace('amd64', 'x64')
|
|
|
|
|
|
def current_system_cpu_brand() -> str:
|
|
"""Fast cross-platform CPU brand string"""
|
|
if sys.platform.startswith('darwin'): # macOS
|
|
try:
|
|
return subprocess.check_output(['sysctl', '-n', 'machdep.cpu.brand_string']).decode().strip()
|
|
except Exception:
|
|
pass
|
|
elif sys.platform.startswith('win'): # Windows
|
|
from winreg import HKEY_LOCAL_MACHINE, OpenKey, QueryValueEx
|
|
try:
|
|
# Open the registry key where Windows stores the CPU name
|
|
key = OpenKey(HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System\CentralProcessor\0")
|
|
# The value name is "ProcessorNameString"
|
|
value, _ = QueryValueEx(key, "ProcessorNameString")
|
|
return value.strip() # Usually perfect, with full marketing name
|
|
except Exception:
|
|
# Fallback: sometimes the key is under a different index, try 1
|
|
try:
|
|
key = OpenKey(HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System\CentralProcessor\1")
|
|
value, _ = QueryValueEx(key, "ProcessorNameString")
|
|
return value.strip()
|
|
except Exception:
|
|
return "Unknown CPU"
|
|
elif sys.platform.startswith('linux'):
|
|
try:
|
|
with open('/proc/cpuinfo') as f:
|
|
for line in f:
|
|
if line.startswith('model name'):
|
|
return line.split(':', 1)[1].strip()
|
|
except Exception:
|
|
pass
|
|
|
|
# Ultimate fallback
|
|
return platform.processor() or 'Unknown CPU'
|
|
|
|
def resources_dir() -> str:
|
|
return os.path.join(os.path.dirname(__file__), '..', '..', 'resources')
|
|
|
|
|
|
def copy_directory_contents(src_dir: str, dst_dir: str) -> None:
|
|
for item in os.listdir(src_dir):
|
|
src_path = os.path.join(src_dir, item)
|
|
dst_path = os.path.join(dst_dir, item)
|
|
if os.path.isdir(src_path):
|
|
shutil.copytree(src_path, dst_path)
|
|
else:
|
|
shutil.copy2(src_path, dst_path)
|
|
|
|
|
|
def check_for_updates(repo_name: str, repo_owner: str, app_name: str, current_version: str) -> Optional[Dict[str, Any]]:
|
|
def get_github_releases(owner, repo):
|
|
import requests
|
|
url = f"https://api.github.com/repos/{owner}/{repo}/releases"
|
|
try:
|
|
response = requests.get(url, timeout=3)
|
|
response.raise_for_status()
|
|
releases = response.json()
|
|
return releases
|
|
except Exception as e:
|
|
logger.error(f"Error checking for updates: {e}")
|
|
return []
|
|
|
|
releases = get_github_releases(repo_owner, repo_name)
|
|
if not releases:
|
|
return None
|
|
|
|
latest_version = releases[0]
|
|
latest_version_tag = latest_version['tag_name']
|
|
|
|
from packaging import version
|
|
if version.parse(latest_version_tag) > version.parse(current_version):
|
|
logger.info(f"Newer version of {app_name} available. "
|
|
f"Latest: {latest_version_tag}, Current: {current_version}")
|
|
return latest_version
|
|
return None
|
|
|
|
def is_localhost(comparison_hostname: str) -> bool:
|
|
return comparison_hostname in ['localhost', '127.0.0.1', socket.gethostname()]
|
|
|
|
|
|
def num_to_alphanumeric(num: int) -> str:
|
|
return string.ascii_letters[num % 26] + str(num // 26)
|
|
|
|
|
|
def get_gpu_info() -> List[Dict[str, Any]]:
|
|
"""Cross-platform GPU information retrieval"""
|
|
|
|
def get_windows_gpu_info():
|
|
"""Get GPU info on Windows"""
|
|
try:
|
|
result = subprocess.run(
|
|
['wmic', 'path', 'win32_videocontroller', 'get', 'name,AdapterRAM', '/format:list'],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
|
|
# Virtual adapters to exclude
|
|
virtual_adapters = [
|
|
'virtual', 'rdp', 'hyper-v', 'microsoft basic', 'basic display',
|
|
'vga compatible', 'dummy', 'nvfbc', 'nvencode'
|
|
]
|
|
|
|
gpus = []
|
|
current_gpu = None
|
|
|
|
for line in result.stdout.strip().split('\n'):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
if line.startswith('Name='):
|
|
if current_gpu and current_gpu.get('name'):
|
|
gpus.append(current_gpu)
|
|
gpu_name = line.replace('Name=', '').strip()
|
|
|
|
# Skip virtual adapters
|
|
if any(virtual in gpu_name.lower() for virtual in virtual_adapters):
|
|
current_gpu = None
|
|
else:
|
|
current_gpu = {'name': gpu_name, 'memory': 'Integrated'}
|
|
|
|
elif line.startswith('AdapterRAM=') and current_gpu:
|
|
vram_bytes_str = line.replace('AdapterRAM=', '').strip()
|
|
if vram_bytes_str and vram_bytes_str != '0':
|
|
try:
|
|
vram_gb = int(vram_bytes_str) / (1024**3)
|
|
current_gpu['memory'] = round(vram_gb, 2)
|
|
except:
|
|
pass
|
|
|
|
if current_gpu and current_gpu.get('name'):
|
|
gpus.append(current_gpu)
|
|
|
|
return gpus if gpus else [{'name': 'Unknown GPU', 'memory': 'Unknown'}]
|
|
except Exception as e:
|
|
logger.error(f"Failed to get Windows GPU info: {e}")
|
|
return [{'name': 'Unknown GPU', 'memory': 'Unknown'}]
|
|
|
|
def get_macos_gpu_info():
|
|
"""Get GPU info on macOS (works with Apple Silicon)"""
|
|
try:
|
|
if current_system_cpu() == "arm64":
|
|
# don't bother with system_profiler with Apple ARM - we know its integrated
|
|
return [{'name': current_system_cpu_brand(), 'memory': 'Integrated'}]
|
|
|
|
result = subprocess.run(['system_profiler', 'SPDisplaysDataType', '-detailLevel', 'mini', '-json'],
|
|
capture_output=True, text=True, timeout=5)
|
|
data = json.loads(result.stdout)
|
|
|
|
gpus = []
|
|
displays = data.get('SPDisplaysDataType', [])
|
|
for display in displays:
|
|
if 'sppci_model' in display:
|
|
gpus.append({
|
|
'name': display.get('sppci_model', 'Unknown GPU'),
|
|
'memory': display.get('sppci_vram', 'Integrated'),
|
|
})
|
|
return gpus if gpus else [{'name': 'Apple GPU', 'memory': 'Integrated'}]
|
|
except Exception as e:
|
|
print(f"Failed to get macOS GPU info: {e}")
|
|
return [{'name': 'Unknown GPU', 'memory': 'Unknown'}]
|
|
|
|
def get_linux_gpu_info():
|
|
gpus = []
|
|
try:
|
|
# Run plain lspci and filter for GPU-related lines
|
|
output = subprocess.check_output(
|
|
["lspci"], universal_newlines=True, stderr=subprocess.DEVNULL
|
|
)
|
|
for line in output.splitlines():
|
|
if any(keyword in line.lower() for keyword in ["vga", "3d", "display"]):
|
|
# Extract the part after the colon (vendor + model)
|
|
if ":" in line:
|
|
name_part = line.split(":", 1)[1].strip()
|
|
# Clean up common extras like (rev xx) or (prog-if ...)
|
|
name = name_part.split("(")[0].split("controller:")[-1].strip()
|
|
vendor = "Unknown"
|
|
if "nvidia" in name.lower():
|
|
vendor = "NVIDIA"
|
|
elif "amd" in name.lower() or "ati" in name.lower():
|
|
vendor = "AMD"
|
|
elif "intel" in name.lower():
|
|
vendor = "Intel"
|
|
|
|
gpus.append({
|
|
"name": name,
|
|
"vendor": vendor,
|
|
"memory": "Unknown"
|
|
})
|
|
except FileNotFoundError:
|
|
print("lspci not found. Install pciutils: sudo apt install pciutils")
|
|
return []
|
|
except Exception as e:
|
|
print(f"Error running lspci: {e}")
|
|
return []
|
|
|
|
return gpus
|
|
|
|
system = platform.system()
|
|
|
|
if system == 'Darwin': # macOS
|
|
return get_macos_gpu_info()
|
|
elif system == 'Windows':
|
|
return get_windows_gpu_info()
|
|
else: # Assume Linux or other
|
|
return get_linux_gpu_info()
|
|
|
|
COMMON_RESOLUTIONS = {
|
|
# SD
|
|
"SD_480p": (640, 480),
|
|
"NTSC_DVD": (720, 480),
|
|
"PAL_DVD": (720, 576),
|
|
|
|
# HD
|
|
"HD_720p": (1280, 720),
|
|
"HD_900p": (1600, 900),
|
|
"HD_1080p": (1920, 1080),
|
|
|
|
# Cinema / Film
|
|
"2K_DCI": (2048, 1080),
|
|
"4K_DCI": (4096, 2160),
|
|
|
|
# UHD / Consumer
|
|
"UHD_4K": (3840, 2160),
|
|
"UHD_5K": (5120, 2880),
|
|
"UHD_8K": (7680, 4320),
|
|
|
|
# Ultrawide / Aspect Variants
|
|
"UW_1080p": (2560, 1080),
|
|
"UW_1440p": (3440, 1440),
|
|
"UW_5K": (5120, 2160),
|
|
|
|
# Mobile / Social
|
|
"VERTICAL_1080x1920": (1080, 1920),
|
|
"SQUARE_1080": (1080, 1080),
|
|
|
|
# Classic / Legacy
|
|
"VGA": (640, 480),
|
|
"SVGA": (800, 600),
|
|
"XGA": (1024, 768),
|
|
"WXGA": (1280, 800),
|
|
}
|
|
|
|
COMMON_FRAME_RATES = {
|
|
"23.976 (NTSC Film)": 23.976,
|
|
"24 (Cinema)": 24.0,
|
|
"25 (PAL)": 25.0,
|
|
"29.97 (NTSC)": 29.97,
|
|
"30": 30.0,
|
|
"48 (HFR Film)": 48.0,
|
|
"50 (PAL HFR)": 50.0,
|
|
"59.94": 59.94,
|
|
"60": 60.0,
|
|
"72": 72.0,
|
|
"90 (VR)": 90.0,
|
|
"120": 120.0,
|
|
"144 (Gaming)": 144.0,
|
|
"240 (HFR)": 240.0,
|
|
} |