Settings Window (#124)

* Initial commit for settings window

* More WIP for the Settings panel

* Added Local Files section to Settings

* More WIP on Settings

* Add ability to ignore system builds

* Improvements to Launch and Delete buttons

* Fix issue where icons were not loading

* Network password settings WIP

* Update label

* Import and naming fixes

* Speed improvements to launch

* Update requirements.txt

* Update Windows CPU name lookup

* Add missing default values to a few settings

* More settings fixes

* Fix Windows Path issue

* Added hard types for getting settings values

* More UI cleanup

* Correctly refresh Engines list after downloading new engine

* Improve downloader with UI progress

* More download improvements

* Add Settings Button to Toolbar
This commit is contained in:
2025-12-28 12:33:29 -06:00
committed by GitHub
parent daf445ee9e
commit 4704806472
13 changed files with 733 additions and 129 deletions

View File

@@ -8,8 +8,7 @@ from src.engines.blender.blender_engine import Blender
from src.engines.core.base_downloader import EngineDownloader
from src.utilities.misc_helper import current_system_os, current_system_cpu
# url = "https://download.blender.org/release/"
url = "https://ftp.nluug.nl/pub/graphics/blender/release/" # much faster mirror for testing
url = "https://download.blender.org/release/"
logger = logging.getLogger()
supported_formats = ['.zip', '.tar.xz', '.dmg']
@@ -88,8 +87,8 @@ class BlenderDownloader(EngineDownloader):
threads = []
results = [[] for _ in majors]
def thread_function(major_version, index, system_os, cpu):
results[index] = cls.__get_minor_versions(major_version, system_os, cpu)
def thread_function(major_version, index, system_os_t, cpu_t):
results[index] = cls.__get_minor_versions(major_version, system_os_t, cpu_t)
for i, m in enumerate(majors):
thread = threading.Thread(target=thread_function, args=(m, i, system_os, cpu))
@@ -126,7 +125,7 @@ class BlenderDownloader(EngineDownloader):
return None
@classmethod
def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120):
def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120, progress_callback=None):
system_os = system_os or current_system_os()
cpu = cpu or current_system_cpu()
@@ -136,7 +135,7 @@ class BlenderDownloader(EngineDownloader):
minor_versions = [x for x in cls.__get_minor_versions(major_version, system_os, cpu) if
x['version'] == version]
cls.download_and_extract_app(remote_url=minor_versions[0]['url'], download_location=download_location,
timeout=timeout)
timeout=timeout, progress_callback=progress_callback)
except IndexError:
logger.error("Cannot find requested engine")

View File

@@ -74,7 +74,7 @@ class EngineDownloader:
raise NotImplementedError(f"version_is_available_to_download not implemented for {cls.__class__.__name__}")
@classmethod
def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120):
def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120, progress_callback=None):
"""Downloads the requested version of the rendering engine to the given download location.
This method should be overridden in a subclass to implement the logic for downloading
@@ -88,6 +88,7 @@ class EngineDownloader:
system_os (str, optional): Desired OS ('linux', 'macos', 'windows'). Defaults to system os.
cpu (str, optional): The CPU architecture for which to download the engine. Default is system cpu.
timeout (int, optional): The maximum time in seconds to wait for the download. Default is 120 seconds.
progress_callback (callable, optional): A callback function that is called periodically with the current download progress.
Raises:
NotImplementedError: If the method is not overridden in a subclass.
@@ -125,7 +126,7 @@ class EngineDownloader:
# --------------------------------------------
@classmethod
def download_and_extract_app(cls, remote_url, download_location, timeout=120):
def download_and_extract_app(cls, remote_url, download_location, timeout=120, progress_callback=None):
"""Downloads an application from the given remote URL and extracts it to the specified location.
This method handles the downloading of the application, supports multiple archive formats,
@@ -136,6 +137,7 @@ class EngineDownloader:
remote_url (str): The URL of the application to download.
download_location (str): The directory where the application should be extracted.
timeout (int, optional): The maximum time in seconds to wait for the download. Default is 120 seconds.
progress_callback (callable, optional): A callback function that is called periodically with the current download progress.
Returns:
str: The path to the directory where the application was extracted.
@@ -154,6 +156,8 @@ class EngineDownloader:
and return without downloading or extracting.
- Temporary files created during the download process are cleaned up after completion.
"""
if progress_callback:
progress_callback(0)
# Create a temp download directory
temp_download_dir = tempfile.mkdtemp()
@@ -166,7 +170,7 @@ class EngineDownloader:
if os.path.exists(os.path.join(download_location, output_dir_name)):
logger.error(f"Engine download for {output_dir_name} already exists")
return
return None
if not os.path.exists(temp_downloaded_file_path):
# Make a GET request to the URL with stream=True to enable streaming
@@ -182,20 +186,26 @@ class EngineDownloader:
progress_bar = tqdm(total=file_size, unit="B", unit_scale=True)
# Open a file for writing in binary mode
total_saved = 0
with open(temp_downloaded_file_path, "wb") as file:
for chunk in response.iter_content(chunk_size=1024):
if chunk:
# Write the chunk to the file
file.write(chunk)
total_saved += len(chunk)
# Update the progress bar
progress_bar.update(len(chunk))
if progress_callback:
percent = float(total_saved) / float(file_size)
progress_callback(percent)
# Close the progress bar
progress_callback(1.0)
progress_bar.close()
logger.info(f"Successfully downloaded {os.path.basename(temp_downloaded_file_path)}")
else:
logger.error(f"Failed to download the file. Status code: {response.status_code}")
return
return None
os.makedirs(download_location, exist_ok=True)

View File

@@ -23,6 +23,14 @@ class EngineManager:
def supported_engines():
return [Blender, FFMPEG]
@classmethod
def downloadable_engines(cls):
return [engine for engine in cls.supported_engines() if hasattr(engine, "downloader") and engine.downloader()]
@classmethod
def active_downloads(cls) -> list:
return [x for x in cls.download_tasks if x.is_alive()]
@classmethod
def engine_with_name(cls, engine_name):
for obj in cls.supported_engines():
@@ -30,7 +38,15 @@ class EngineManager:
return obj
@classmethod
def get_engines(cls, filter_name=None, include_corrupt=False):
def update_all_engines(cls):
for engine in cls.downloadable_engines():
update_available = cls.is_engine_update_available(engine)
if update_available:
update_available['name'] = engine.name()
cls.download_engine(engine.name(), update_available['version'], background=True)
@classmethod
def get_engines(cls, filter_name=None, include_corrupt=False, ignore_system=False):
if not cls.engines_path:
raise FileNotFoundError("Engine path is not set")
@@ -63,13 +79,13 @@ class EngineManager:
)
result_dict['path'] = path
# fetch version number from binary - helps detect corrupted downloads
binary_version = eng(path).version()
if not binary_version:
logger.warning(f"Possible corrupt {eng.name()} {result_dict['version']} install detected: {path}")
if not include_corrupt:
continue
result_dict['version'] = binary_version or 'error'
# fetch version number from binary - helps detect corrupted downloads - disabled due to perf issues
# binary_version = eng(path).version()
# if not binary_version:
# logger.warning(f"Possible corrupt {eng.name()} {result_dict['version']} install detected: {path}")
# if not include_corrupt:
# continue
# result_dict['version'] = binary_version or 'error'
# Add the result dictionary to results if it matches the filter_name or if no filter is applied
if not filter_name or filter_name == result_dict['engine']:
@@ -92,46 +108,47 @@ class EngineManager:
'type': 'system'
}
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = {
executor.submit(fetch_engine_details, eng, include_corrupt): eng.name()
for eng in cls.supported_engines()
if eng.default_engine_path() and (not filter_name or filter_name == eng.name())
}
if not ignore_system:
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = {
executor.submit(fetch_engine_details, eng, include_corrupt): eng.name()
for eng in cls.supported_engines()
if eng.default_engine_path() and (not filter_name or filter_name == eng.name())
}
for future in concurrent.futures.as_completed(futures):
result = future.result()
if result:
results.append(result)
for future in concurrent.futures.as_completed(futures):
result = future.result()
if result:
results.append(result)
return results
@classmethod
def all_versions_for_engine(cls, engine_name, include_corrupt=False):
versions = cls.get_engines(filter_name=engine_name, include_corrupt=include_corrupt)
def all_versions_for_engine(cls, engine_name, include_corrupt=False, ignore_system=False):
versions = cls.get_engines(filter_name=engine_name, include_corrupt=include_corrupt, ignore_system=ignore_system)
sorted_versions = sorted(versions, key=lambda x: x['version'], reverse=True)
return sorted_versions
@classmethod
def newest_engine_version(cls, engine, system_os=None, cpu=None):
def newest_engine_version(cls, engine, system_os=None, cpu=None, ignore_system=None):
system_os = system_os or current_system_os()
cpu = cpu or current_system_cpu()
try:
filtered = [x for x in cls.all_versions_for_engine(engine) if x['system_os'] == system_os and
x['cpu'] == cpu]
filtered = [x for x in cls.all_versions_for_engine(engine, ignore_system=ignore_system)
if x['system_os'] == system_os and x['cpu'] == cpu]
return filtered[0]
except IndexError:
logger.error(f"Cannot find newest engine version for {engine}-{system_os}-{cpu}")
return None
@classmethod
def is_version_downloaded(cls, engine, version, system_os=None, cpu=None):
def is_version_downloaded(cls, engine, version, system_os=None, cpu=None, ignore_system=False):
system_os = system_os or current_system_os()
cpu = cpu or current_system_cpu()
filtered = [x for x in cls.get_engines(filter_name=engine) if x['system_os'] == system_os and
x['cpu'] == cpu and x['version'] == version]
filtered = [x for x in cls.get_engines(filter_name=engine, ignore_system=ignore_system) if
x['system_os'] == system_os and x['cpu'] == cpu and x['version'] == version]
return filtered[0] if filtered else False
@classmethod
@@ -164,7 +181,7 @@ class EngineManager:
return None
@classmethod
def download_engine(cls, engine, version, system_os=None, cpu=None, background=False):
def download_engine(cls, engine, version, system_os=None, cpu=None, background=False, ignore_system=False):
engine_to_download = cls.engine_with_name(engine)
existing_task = cls.get_existing_download_task(engine, version, system_os, cpu)
@@ -172,10 +189,10 @@ class EngineManager:
logger.debug(f"Already downloading {engine} {version}")
if not background:
existing_task.join() # If download task exists, wait until it's done downloading
return
return None
elif not engine_to_download.downloader():
logger.warning("No valid downloader for this engine. Please update this software manually.")
return
return None
elif not cls.engines_path:
raise FileNotFoundError("Engines path must be set before requesting downloads")
@@ -187,7 +204,7 @@ class EngineManager:
return thread
thread.join()
found_engine = cls.is_version_downloaded(engine, version, system_os, cpu) # Check that engine downloaded
found_engine = cls.is_version_downloaded(engine, version, system_os, cpu, ignore_system) # Check that engine downloaded
if not found_engine:
logger.error(f"Error downloading {engine}")
return found_engine
@@ -213,31 +230,21 @@ class EngineManager:
return False
@classmethod
def update_all_engines(cls):
def engine_update_task(engine_class):
logger.debug(f"Checking for updates to {engine_class.name()}")
latest_version = engine_class.downloader().find_most_recent_version()
def is_engine_update_available(cls, engine_class, ignore_system_installs=False):
logger.debug(f"Checking for updates to {engine_class.name()}")
latest_version = engine_class.downloader().find_most_recent_version()
if not latest_version:
logger.warning(f"Could not find most recent version of {engine.name()} to download")
return
if not latest_version:
logger.warning(f"Could not find most recent version of {engine_class.name()} to download")
return None
version_num = latest_version.get('version')
if cls.is_version_downloaded(engine_class.name(), version_num):
logger.debug(f"Latest version of {engine_class.name()} ({version_num}) already downloaded")
return
version_num = latest_version.get('version')
if cls.is_version_downloaded(engine_class.name(), version_num, ignore_system=ignore_system_installs):
logger.debug(f"Latest version of {engine_class.name()} ({version_num}) already downloaded")
return None
# download the engine
logger.info(f"Downloading latest version of {engine_class.name()} ({version_num})...")
cls.download_engine(engine=engine_class.name(), version=version_num, background=True)
return latest_version
logger.info(f"Checking for updates for render engines...")
threads = []
for engine in cls.supported_engines():
if engine.downloader():
thread = threading.Thread(target=engine_update_task, args=(engine,))
threads.append(thread)
thread.start()
@classmethod
def create_worker(cls, engine_name, input_path, output_path, engine_version=None, args=None, parent=None, name=None):
@@ -303,18 +310,23 @@ class EngineDownloadWorker(threading.Thread):
self.version = version
self.system_os = system_os
self.cpu = cpu
self.percent_complete = 0
def _update_progress(self, current_progress):
self.percent_complete = current_progress
def run(self):
try:
existing_download = EngineManager.is_version_downloaded(self.engine, self.version, self.system_os, self.cpu)
existing_download = EngineManager.is_version_downloaded(self.engine, self.version, self.system_os, self.cpu,
ignore_system=True)
if existing_download:
logger.info(f"Requested download of {self.engine} {self.version}, but local copy already exists")
return existing_download
# Get the appropriate downloader class based on the engine type
EngineManager.engine_with_name(self.engine).downloader().download_engine(
self.version, download_location=EngineManager.engines_path, system_os=self.system_os, cpu=self.cpu,
timeout=300)
downloader = EngineManager.engine_with_name(self.engine).downloader()
downloader.download_engine( self.version, download_location=EngineManager.engines_path,
system_os=self.system_os, cpu=self.cpu, timeout=300, progress_callback=self._update_progress)
except Exception as e:
logger.error(f"Error in download worker: {e}")
finally:

View File

@@ -97,7 +97,7 @@ class FFMPEGDownloader(EngineDownloader):
'windows': cls.__get_windows_versions}
if not versions_per_os.get(system_os):
logger.error(f"Cannot find version list for {system_os}")
return
return None
results = []
all_versions = versions_per_os[system_os]()
@@ -144,7 +144,7 @@ class FFMPEGDownloader(EngineDownloader):
return None
@classmethod
def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120):
def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120, progress_callback=None):
system_os = system_os or current_system_os()
cpu = cpu or current_system_cpu()
@@ -152,7 +152,7 @@ class FFMPEGDownloader(EngineDownloader):
found_version = [item for item in cls.all_versions(system_os, cpu) if item['version'] == version]
if not found_version:
logger.error(f"Cannot find FFMPEG version {version} for {system_os} and {cpu}")
return
return None
# Platform specific naming cleanup
remote_url = cls.__get_remote_url_for_version(version=version, system_os=system_os, cpu=cpu)
@@ -162,7 +162,8 @@ class FFMPEGDownloader(EngineDownloader):
# Download and extract
try:
logger.info(f"Requesting download of ffmpeg-{version}-{system_os}-{cpu}")
cls.download_and_extract_app(remote_url=remote_url, download_location=download_location, timeout=timeout)
cls.download_and_extract_app(remote_url=remote_url, download_location=download_location, timeout=timeout,
progress_callback=progress_callback)
# naming cleanup to match existing naming convention
output_path = os.path.join(download_location, f'ffmpeg-{version}-{system_os}-{cpu}')