5 Commits

Author SHA1 Message Date
Brett Williams 250aa22557 Update proxy to use similar named methods to new API calls 2026-06-06 01:16:53 -05:00
Brett Williams 95341e815c Remove redundant installed_engines endpoint 2026-06-06 01:03:16 -05:00
Brett Williams f3d469af53 Delete engine API cleanup 2026-06-06 00:54:43 -05:00
Brett Williams b8f025ccba Change api methods to use POST when possible 2026-06-06 00:49:56 -05:00
Brett Williams 076eebcdac Consolidate engine_info api calls 2026-06-06 00:34:48 -05:00
7 changed files with 122 additions and 115 deletions
+21 -10
View File
@@ -83,18 +83,24 @@ The system works by:
Jobs can be submitted via the desktop UI or programmatically via the API: Jobs can be submitted via the desktop UI or programmatically via the API:
- **Via UI**: Use the desktop interface to upload project files, specify render settings, and queue jobs. - **Via UI**: Use the desktop interface to upload project files, specify render settings, and queue jobs.
- **Via API**: Send POST requests to `/api/jobs` with job configuration in JSON format. - **Via API**: Send `POST` requests to `/api/add_job` with job configuration in JSON format.
Example API request: Example API request:
```bash ```bash
curl -X POST http://localhost:5000/api/jobs \ curl -X POST http://localhost:8080/api/add_job \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"engine": "blender", "name": "example-render",
"project_path": "/path/to/project.blend", "engine_name": "blender",
"output_path": "/path/to/output", "local_path": "/path/to/project.blend",
"frames": "1-100", "output_path": "example-output",
"settings": {"resolution": "1920x1080"} "start_frame": 1,
"end_frame": 100,
"args": {
"export_format": "PNG",
"resolution": [1920, 1080]
},
"enable_split_jobs": false
}' }'
``` ```
@@ -103,9 +109,14 @@ curl -X POST http://localhost:5000/api/jobs \
- **UI**: View job status, progress, logs, and worker availability in real-time. - **UI**: View job status, progress, logs, and worker availability in real-time.
- **API Endpoints**: - **API Endpoints**:
- `GET /api/jobs`: List all jobs - `GET /api/jobs`: List all jobs
- `GET /api/jobs/{id}`: Get job details - `GET /api/job/<job_id>`: Get job details
- `DELETE /api/jobs/{id}`: Cancel a job - `POST /api/job/<job_id>/cancel`: Cancel a job
- `GET /api/workers`: List connected workers - `POST /api/job/<job_id>/delete`: Delete a job
- `GET /api/status`: Get server and queue status
- `GET /api/engines`: List engine information
For the full endpoint reference, see [`docs/api.html`](docs/api.html) or
[`docs/API.md`](docs/API.md).
#### Worker Management #### Worker Management
+67 -79
View File
@@ -17,6 +17,7 @@ import psutil
import yaml import yaml
from flask import Flask, request, send_file, after_this_request, Response, redirect, url_for from flask import Flask, request, send_file, after_this_request, Response, redirect, url_for
from sqlalchemy.orm.exc import DetachedInstanceError from sqlalchemy.orm.exc import DetachedInstanceError
from werkzeug.exceptions import HTTPException
from src.api.job_import_handler import JobImportHandler from src.api.job_import_handler import JobImportHandler
from src.api.preview_manager import PreviewManager from src.api.preview_manager import PreviewManager
@@ -306,7 +307,7 @@ def add_job_handler():
return 'unknown error', 500 return 'unknown error', 500
@server.get('/api/job/<job_id>/cancel') @server.post('/api/job/<job_id>/cancel')
def cancel_job(job_id): def cancel_job(job_id):
if not request.args.get('confirm', False): if not request.args.get('confirm', False):
return 'Confirmation required to cancel job', 400 return 'Confirmation required to cancel job', 400
@@ -320,7 +321,7 @@ def cancel_job(job_id):
return "Unknown error", 500 return "Unknown error", 500
@server.route('/api/job/<job_id>/delete', methods=['POST', 'GET']) @server.post('/api/job/<job_id>/delete')
def delete_job(job_id): def delete_job(job_id):
try: try:
if not request.args.get("confirm", False): if not request.args.get("confirm", False):
@@ -379,93 +380,38 @@ def get_engine_for_filename():
return f"Error: cannot find a suitable engine for '{filename}'", 400 return f"Error: cannot find a suitable engine for '{filename}'", 400
return found_engine.name() return found_engine.name()
@server.get('/api/installed_engines') def _validated_engine_response_type():
def get_installed_engines():
result = {}
for engine_class in EngineManager.supported_engines():
data = EngineManager.all_version_data_for_engine(engine_class.name())
if data:
result[engine_class.name()] = data
return result
@server.get('/api/engine_info')
def engine_info():
response_type = request.args.get('response_type', 'standard') response_type = request.args.get('response_type', 'standard')
if response_type not in ['full', 'standard']: if response_type not in ['full', 'standard']:
raise ValueError(f"Invalid response_type: {response_type}") raise ValueError(f"Invalid response_type: {response_type}")
return response_type
def process_engine(engine):
try:
# Get all installed versions of the engine
installed_versions = EngineManager.all_version_data_for_engine(engine.name())
if not installed_versions:
return None
system_installed_versions = [v for v in installed_versions if v['type'] == 'system']
install_path = system_installed_versions[0]['path'] if system_installed_versions else installed_versions[0]['path']
en = engine(install_path)
engine_name = en.name()
result = {
engine_name: {
'is_available': RenderQueue.is_available_for_job(engine_name),
'versions': installed_versions
}
}
if response_type == 'full':
with concurrent.futures.ThreadPoolExecutor() as executor:
future_results = {
'supported_extensions': executor.submit(en.supported_extensions),
'supported_export_formats': executor.submit(en.get_output_formats),
'system_info': executor.submit(en.system_info)
}
for key, future in future_results.items():
result[engine_name][key] = future.result()
return result
except Exception as e:
traceback.print_exc(e)
logger.error(f"Error fetching details for engine '{engine.name()}': {e}")
return {}
engine_data = {}
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = {executor.submit(process_engine, engine): engine.name() for engine in EngineManager.supported_engines()}
for future in concurrent.futures.as_completed(futures):
result = future.result()
if result:
engine_data.update(result)
return engine_data
@server.get('/api/<engine_name>/info') def _engine_info_for_engine(engine_class, response_type='standard'):
def get_engine_info(engine_name):
try: try:
response_type = request.args.get('response_type', 'standard') installed_versions = EngineManager.all_version_data_for_engine(engine_class.name())
# Get all installed versions of the engine
installed_versions = EngineManager.all_version_data_for_engine(engine_name)
if not installed_versions: if not installed_versions:
return {} return None
result = { 'is_available': RenderQueue.is_available_for_job(engine_name), system_installed_versions = [v for v in installed_versions if v['type'] == 'system']
'versions': installed_versions install_path = (
} system_installed_versions[0]['path'] if system_installed_versions else installed_versions[0]['path']
)
engine = engine_class(install_path)
engine_name = engine.name()
result = {
'is_available': RenderQueue.is_available_for_job(engine_name),
'versions': installed_versions
}
if response_type == 'full': if response_type == 'full':
with concurrent.futures.ThreadPoolExecutor() as executor: with concurrent.futures.ThreadPoolExecutor() as executor:
engine_class = EngineManager.engine_class_with_name(engine_name)
en = EngineManager.get_latest_engine_instance(engine_class)
future_results = { future_results = {
'supported_extensions': executor.submit(en.supported_extensions), 'supported_extensions': executor.submit(engine.supported_extensions),
'supported_export_formats': executor.submit(en.get_output_formats), 'supported_export_formats': executor.submit(engine.get_output_formats),
'system_info': executor.submit(en.system_info), 'system_info': executor.submit(engine.system_info),
'options': executor.submit(en.ui_options) 'options': executor.submit(engine.ui_options)
} }
for key, future in future_results.items(): for key, future in future_results.items():
@@ -473,13 +419,52 @@ def get_engine_info(engine_name):
return result return result
except Exception as e:
logger.error(f"Error fetching details for engine '{engine_class.name()}': {e}")
return {}
@server.get('/api/engines')
def get_engines_info():
response_type = _validated_engine_response_type()
engine_data = {}
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = {
executor.submit(_engine_info_for_engine, engine, response_type): engine.name()
for engine in EngineManager.supported_engines()
}
for future in concurrent.futures.as_completed(futures):
result = future.result()
if result:
engine_data[futures[future]] = result
return engine_data
@server.get('/api/engines/names')
def get_engine_names():
result = []
for engine_class in EngineManager.supported_engines():
data = EngineManager.all_version_data_for_engine(engine_class.name())
if data:
result.append(engine_class.name())
return result
@server.get('/api/engines/<engine_name>')
def get_engine(engine_name):
try:
response_type = _validated_engine_response_type()
engine_class = EngineManager.engine_class_with_name(engine_name)
return _engine_info_for_engine(engine_class, response_type) or {}
except Exception as e: except Exception as e:
logger.error(f"Error fetching details for engine '{engine_name}': {e}") logger.error(f"Error fetching details for engine '{engine_name}': {e}")
return {} return {}
@server.get('/api/<engine_name>/is_available') @server.get('/api/<engine_name>/is_available')
def is_engine_available(engine_name): def get_engine_availability(engine_name):
return {'engine': engine_name, 'available': RenderQueue.is_available_for_job(engine_name), return {'engine': engine_name, 'available': RenderQueue.is_available_for_job(engine_name),
'cpu_count': int(psutil.cpu_count(logical=False)), 'cpu_count': int(psutil.cpu_count(logical=False)),
'versions': EngineManager.all_version_data_for_engine(engine_name), 'versions': EngineManager.all_version_data_for_engine(engine_name),
@@ -632,6 +617,9 @@ def handle_404(error):
@server.errorhandler(Exception) @server.errorhandler(Exception)
def handle_general_error(general_error): def handle_general_error(general_error):
if isinstance(general_error, HTTPException):
return general_error.description, general_error.code
traceback.print_exception(type(general_error), general_error, general_error.__traceback__) traceback.print_exception(type(general_error), general_error, general_error.__traceback__)
err_msg = f"Server error: {general_error}" err_msg = f"Server error: {general_error}"
logger.error(err_msg) logger.error(err_msg)
@@ -649,7 +637,7 @@ def detected_clients():
return ZeroconfServer.found_hostnames() return ZeroconfServer.found_hostnames()
@server.get('/api/_debug/clear_history') @server.post('/api/_debug/clear_history')
def clear_history(): def clear_history():
RenderQueue.clear_history() RenderQueue.clear_history()
return 'success' return 'success'
+22 -16
View File
@@ -109,6 +109,11 @@ class RenderServerProxy:
return requests.get(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout, return requests.get(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout,
headers={"X-API-Version": str(API_VERSION)}) headers={"X-API-Version": str(API_VERSION)})
def _post(self, payload, timeout=5, **kwargs):
from src.api.api_server import API_VERSION
return requests.post(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout,
headers={"X-API-Version": str(API_VERSION)}, **kwargs)
# -------------------------------------------- # --------------------------------------------
# Background Updates: # Background Updates:
# -------------------------------------------- # --------------------------------------------
@@ -225,10 +230,10 @@ class RenderServerProxy:
return response return response
def cancel_job(self, job_id, confirm=False): def cancel_job(self, job_id, confirm=False):
return self.request_data(f'job/{job_id}/cancel?confirm={confirm}') return self._post(f'job/{job_id}/cancel', params={'confirm': confirm})
def delete_job(self, job_id, confirm=False): def delete_job(self, job_id, confirm=False):
return self.request_data(f'job/{job_id}/delete?confirm={confirm}') return self._post(f'job/{job_id}/delete', params={'confirm': confirm})
def send_subjob_update_notification(self, parent_id, subjob): def send_subjob_update_notification(self, parent_id, subjob):
""" """
@@ -252,15 +257,15 @@ class RenderServerProxy:
response = self.request(f'engine_for_filename?filename={os.path.basename(filename)}', timeout) response = self.request(f'engine_for_filename?filename={os.path.basename(filename)}', timeout)
return response.text return response.text
def get_installed_engines(self, timeout=5): def get_engine_availability(self, engine_name:str, timeout=5):
return self.request_data(f'installed_engines', timeout)
def is_engine_available(self, engine_name:str, timeout=5):
return self.request_data(f'{engine_name}/is_available', timeout) return self.request_data(f'{engine_name}/is_available', timeout)
def get_all_engine_info(self, response_type='standard', timeout=5): def get_engine_names(self, timeout=5):
return self.request_data('engines/names', timeout=timeout)
def get_engines(self, response_type='standard', timeout=5):
""" """
Fetches all engine information from the server. Fetches engine information from the server.
Args: Args:
response_type (str, optional): Returns standard or full version of engine info response_type (str, optional): Returns standard or full version of engine info
@@ -269,10 +274,10 @@ class RenderServerProxy:
Returns: Returns:
dict: A dictionary containing the engine information. dict: A dictionary containing the engine information.
""" """
all_data = self.request_data(f"engine_info?response_type={response_type}", timeout=timeout) all_data = self.request_data(f'engines?response_type={response_type}', timeout=timeout)
return all_data return all_data
def get_engine_info(self, engine_name:str, response_type='standard', timeout=5): def get_engine(self, engine_name:str, response_type='standard', timeout=5):
""" """
Fetches specific engine information from the server. Fetches specific engine information from the server.
@@ -284,22 +289,23 @@ class RenderServerProxy:
Returns: Returns:
dict: A dictionary containing the engine information. dict: A dictionary containing the engine information.
""" """
return self.request_data(f'{engine_name}/info?response_type={response_type}', timeout) return self.request_data(f'engines/{engine_name}?response_type={response_type}', timeout)
def delete_engine(self, engine_name:str, version:str, system_cpu=None): def delete_engine_download(self, engine_name:str, version:str, system_os=None, cpu=None):
""" """
Sends a request to the server to delete a specific engine. Sends a request to the server to delete a specific engine download.
Args: Args:
engine_name (str): The name of the engine to delete. engine_name (str): The name of the engine to delete.
version (str): The version of the engine to delete. version (str): The version of the engine to delete.
system_cpu (str, optional): The system CPU type. Defaults to None. system_os (str, optional): The system OS. Defaults to None.
cpu (str, optional): The system CPU type. Defaults to None.
Returns: Returns:
Response: The response from the server. Response: The response from the server.
""" """
form_data = {'engine': engine_name, 'version': version, 'system_cpu': system_cpu} form_data = {'engine': engine_name, 'version': version, 'system_os': system_os, 'cpu': cpu}
return requests.post(f'http://{self.hostname}:{self.port}/api/delete_engine', json=form_data) return self._post('delete_engine', json=form_data)
# -------------------------------------------- # --------------------------------------------
# Download Files: # Download Files:
+1 -1
View File
@@ -276,7 +276,7 @@ class DistributedJobManager:
host_properties = ZeroconfServer.get_hostname_properties(hostname) host_properties = ZeroconfServer.get_hostname_properties(hostname)
if host_properties.get('api_version') == API_VERSION: if host_properties.get('api_version') == API_VERSION:
if not system_os or (system_os and system_os == host_properties.get('system_os')): if not system_os or (system_os and system_os == host_properties.get('system_os')):
response = RenderServerProxy(hostname).is_engine_available(engine_name) response = RenderServerProxy(hostname).get_engine_availability(engine_name)
if response and response.get('available', False): if response and response.get('available', False):
found_available_servers.append(response) found_available_servers.append(response)
+4 -4
View File
@@ -66,7 +66,7 @@ class NewRenderJobForm(QWidget):
# Job / Server Data # Job / Server Data
self.server_proxy = RenderServerProxy(socket.gethostname()) self.server_proxy = RenderServerProxy(socket.gethostname())
self.project_info = None self.project_info = None
self.installed_engines = {} self.installed_engines = []
self.preferred_engine = None self.preferred_engine = None
# Setup # Setup
@@ -345,7 +345,7 @@ class NewRenderJobForm(QWidget):
self.engine_version_combo.addItem('latest') self.engine_version_combo.addItem('latest')
self.file_format_combo.clear() self.file_format_combo.clear()
if current_engine: if current_engine:
engine_info = self.server_proxy.get_engine_info(current_engine, 'full', timeout=10) engine_info = self.server_proxy.get_engine(current_engine, 'full', timeout=10)
self.current_engine_options = engine_info.get('options', []) self.current_engine_options = engine_info.get('options', [])
if not engine_info: if not engine_info:
raise FileNotFoundError(f"Cannot get information about engine '{current_engine}'") raise FileNotFoundError(f"Cannot get information about engine '{current_engine}'")
@@ -404,7 +404,7 @@ class NewRenderJobForm(QWidget):
"""Called by the GetProjectInfoWorker - Do not call directly.""" """Called by the GetProjectInfoWorker - Do not call directly."""
try: try:
self.engine_type.addItems(self.installed_engines.keys()) self.engine_type.addItems(self.installed_engines)
self.engine_type.setCurrentText(self.preferred_engine) self.engine_type.setCurrentText(self.preferred_engine)
self.engine_changed() self.engine_changed()
@@ -633,7 +633,7 @@ class GetProjectInfoWorker(QThread):
def run(self): def run(self):
try: try:
# get the engine info and add them all to the ui # get the engine info and add them all to the ui
self.window.installed_engines = self.window.server_proxy.get_installed_engines() self.window.installed_engines = self.window.server_proxy.get_engine_names()
# select the best engine for the file type # select the best engine for the file type
self.window.preferred_engine = self.window.server_proxy.get_engine_for_filename(self.project_path) self.window.preferred_engine = self.window.server_proxy.get_engine_for_filename(self.project_path)
+4 -2
View File
@@ -93,7 +93,7 @@ class EngineBrowserWindow(QMainWindow):
def update_table(self): def update_table(self):
def update_table_worker(): def update_table_worker():
raw_server_data = RenderServerProxy(self.hostname).get_all_engine_info() raw_server_data = RenderServerProxy(self.hostname).get_engines()
if not raw_server_data: if not raw_server_data:
return return
@@ -158,7 +158,9 @@ class EngineBrowserWindow(QMainWindow):
if reply is not QMessageBox.StandardButton.Yes: if reply is not QMessageBox.StandardButton.Yes:
return return
result = RenderServerProxy(self.hostname).delete_engine(engine_info['engine'], engine_info['version']) result = RenderServerProxy(self.hostname).delete_engine_download(
engine_info['engine'], engine_info['version'], engine_info.get('system_os'), engine_info.get('cpu'),
)
if result.ok: if result.ok:
self.update_table() self.update_table()
else: else:
+1 -1
View File
@@ -37,7 +37,7 @@ class GetEngineInfoWorker(QThread):
self.parent = parent self.parent = parent
def run(self): def run(self):
data = RenderServerProxy(socket.gethostname()).get_all_engine_info() data = RenderServerProxy(socket.gethostname()).get_engines()
self.done.emit(data) self.done.emit(data)
class SettingsWindow(QMainWindow): class SettingsWindow(QMainWindow):