Files
blender-portable-repo/extensions/user_default/blenderkit/timer.py
T
2026-03-17 15:25:32 -06:00

450 lines
17 KiB
Python

# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import logging
import os
import queue
import requests
import bpy
from . import (
addon_updater_ops,
asset_bar_op,
bg_blender,
bkit_oauth,
categories,
client_lib,
client_tasks,
comments_utils,
disclaimer_op,
download,
global_vars,
persistent_preferences,
ratings_utils,
reports,
search,
tasks_queue,
upload,
utils,
)
bk_logger = logging.getLogger(__name__)
reports_queue: queue.Queue = queue.Queue()
pending_tasks = (
list()
) # pending tasks are tasks that were not parsed correclty and should be tried to be parsed later.
def handle_failed_reports(exception: Exception) -> float:
"""Function reacting to failing reports (Client is not accessible).
On 11th, 21st, 31st etc. it will print error message and start Client on other ports.
Iterating over the available ports for each start. Users did not want to change the
ports manually, so we do this automatically for them.
"""
global_vars.CLIENT_ACCESSIBLE = False
global_vars.CLIENT_FAILED_REPORTS += 1 # De facto means we count from 1, not from 0
# First failed report -> lets start the Client
if global_vars.CLIENT_FAILED_REPORTS == 1:
### Expected - port probably free as connection was refused
if isinstance(exception, requests.ConnectionError):
bk_logger.info(
f"Expectedly, first request for BKClient reports failed: {str(exception).strip()} {type(exception)}"
)
### Something unsupported runs on the port (other program, or Client refusing for version reasons)
elif isinstance(exception, requests.HTTPError):
bk_logger.info(
f"First request for BKClient reports was rejected: {str(exception).strip()} {type(exception)}. Port is occupied and has to be changed"
)
client_lib.reorder_ports()
# Not so expected
else:
bk_logger.warning(
f"First request for BKClient reports failed unexpectedly: {str(exception).strip()} {type(exception)}"
)
client_lib.start_blenderkit_client()
else:
bk_logger.warning(
f"Request for BKClient reports failed: {str(exception).strip()} {type(exception)}"
)
if global_vars.CLIENT_FAILED_REPORTS <= 10: # try 10 times
return 0.1 * global_vars.CLIENT_FAILED_REPORTS
# MORE THAN 10 FAILURES - enough time for the Client to get up and running
# so we need to investigate why it failed to start and respond correctly
log_msg = f"Could not get reports ({global_vars.CLIENT_FAILED_REPORTS}. failure): {str(exception).strip()} {type(exception)}"
return_code, meaning = client_lib.check_blenderkit_client_return_code()
# On FAILED_REPORTS == 11, 21, 31...
if global_vars.CLIENT_FAILED_REPORTS % 10 == 1:
reports.add_report(log_msg, 5, "ERROR") # Let's show the message to user
if return_code == -1:
msg = "Client is not responding, add-on will not work."
reports.add_report(msg, timeout=10, type="ERROR")
if return_code != -1:
msg = f"Client failed to start, add-on will not work. Error({return_code}): {meaning}"
reports.add_report(msg, timeout=10, type="ERROR")
# LETS START AGAIN - on different port
# The catch is that the error message printed to user is outdated now.
# But there is not a better solution.
client_lib.reorder_ports()
client_lib.start_blenderkit_client()
else: # On FAILED_REPORTS == 12..20,22..30,32..40 we just log into terminal
bk_logger.warning(log_msg)
wm = bpy.context.window_manager
wm.blenderkitUI.logo_status = "logo_offline" # type: ignore[attr-defined]
global_vars.CLIENT_RUNNING = False
# Gradually retry less frequently, but at least once in 30s...
return min(30.0, 0.1 * global_vars.CLIENT_FAILED_REPORTS)
@bpy.app.handlers.persistent
def client_communication_timer():
"""Receive all responses from Client and run according followup commands.
This function is the only one responsible for keeping the Client up and running.
"""
global pending_tasks
bk_logger.log(5, "Getting tasks from Client")
user_preferences = bpy.context.preferences.addons[__package__].preferences
if user_preferences.use_clipboard_scan:
search.check_clipboard()
results = list()
try:
results = client_lib.get_reports(os.getpid())
global_vars.CLIENT_FAILED_REPORTS = 0
except Exception as e:
return handle_failed_reports(e)
if global_vars.CLIENT_ACCESSIBLE is False:
bk_logger.info(
f"BlenderKit-Client is running on port {global_vars.CLIENT_PORTS[0]}!"
)
global_vars.CLIENT_ACCESSIBLE = True
wm = bpy.context.window_manager
wm.blenderkitUI.logo_status = "logo"
bk_logger.log(5, "Handling tasks")
results_converted_tasks = []
# convert to task type
for task in results:
task = client_tasks.Task(
data=task["data"],
task_id=task["task_id"],
app_id=task["app_id"],
task_type=task["task_type"],
message=task["message"],
message_detailed=task["message_detailed"],
progress=task["progress"],
status=task["status"],
result=task["result"],
)
results_converted_tasks.append(task)
# add pending tasks which were already parsed but not handled
results_converted_tasks.extend(pending_tasks)
pending_tasks.clear()
for task in results_converted_tasks:
handle_task(task)
bk_logger.log(5, "Task handling finished")
delay = user_preferences.client_polling
if len(download.download_tasks) > 0:
return min(0.2, delay)
return delay
@bpy.app.handlers.persistent
def timer_image_cleanup():
imgs = bpy.data.images[:]
for i in imgs:
if (
(i.name[:11] == ".thumbnail_" or i.filepath.find("bkit_g") > -1)
and not i.has_data
and i.users == 0
):
bpy.data.images.remove(i)
return 60
def save_prefs_cancel_all_tasks_and_restart_client(user_preferences, context):
"""Save preferences, cancel all blenderkit-client tasks, shutdown the blenderkit-client and reorder ports.
Unset the CLIENT_FAILED_REPORTS and restart client_communication_timer() so add-on will check for the reports ASAP.
Timer func client_communication_timer() will take care of starting the Client and checking the reports.
"""
utils.save_prefs(user_preferences, context)
if user_preferences.preferences_lock == True:
return
reports.add_report("Restarting Client server", timeout=2)
try:
cancel_all_tasks(user_preferences, context)
client_lib.shutdown_client()
except Exception as e:
bk_logger.warning(str(e))
client_lib.reorder_ports(
user_preferences.client_port
) # reorder after shutdown was requested
global_vars.CLIENT_FAILED_REPORTS = 0 # reset failed reports so next attempt to get report or start client is immediate
bpy.app.timers.unregister(client_communication_timer)
bpy.app.timers.register(client_communication_timer, persistent=True)
def trusted_CA_certs_property_updated(user_preferences, context):
"""Update trusted CA certs environment variables and call save_prefs()."""
update_trusted_CA_certs(user_preferences.trusted_ca_certs)
return save_prefs_cancel_all_tasks_and_restart_client(user_preferences, context)
def update_trusted_CA_certs(certs: str):
if certs == "":
os.environ.pop("REQUESTS_CA_BUNDLE", None)
os.environ.pop("CURL_CA_BUNDLE", None)
return
os.environ["REQUESTS_CA_BUNDLE"] = certs
os.environ["CURL_CA_BUNDLE"] = certs
return
def cancel_all_tasks(self, context):
"""Cancel all tasks."""
global pending_tasks
pending_tasks.clear()
download.clear_downloads()
search.clear_searches()
# TODO: should add uploads
def task_error_overdrive(task: client_tasks.Task) -> None:
"""Handle error task - overdrive some error messages, trigger functions common for all errors."""
if task.message.count("Invalid token.") > 0 and utils.user_logged_in():
preferences = bpy.context.preferences.addons[__package__].preferences # type: ignore
# Invalid token and api_key_refresh present -> trying to refresh the token
if preferences.api_key_refresh != "": # type: ignore
client_lib.refresh_token(preferences.api_key_refresh, preferences.api_key) # type: ignore
msg = "Invalid API key token. Refreshing the token now. If problem persist, please log-out and log-in."
reports.add_report(msg, type="ERROR")
return
# Invalid token and no api_key_refresh token -> nothing else we can try...
bkit_oauth.logout()
msg = "Invalid permanent API key token. Logged out. Please login again."
reports.add_report(msg, type="ERROR")
def handle_task(task: client_tasks.Task):
"""Handle incomming task information. Sort tasks by type and call apropriate functions."""
if task.status == "error":
task_error_overdrive(task)
# HANDLE ASSET DOWNLOAD
if task.task_type == "asset_download":
return download.handle_download_task(task)
# HANDLE ASSET UPLOAD
if task.task_type == "asset_upload":
return upload.handle_asset_upload(task)
if task.task_type == "asset_metadata_upload":
return upload.handle_asset_metadata_upload(task)
# HANDLE SEARCH (candidate to be a function)
if task.task_type == "search":
if task.status == "finished":
return search.handle_search_task(task)
elif task.status == "error":
return search.handle_search_task_error(task)
# HANDLE THUMBNAIL DOWNLOAD (candidate to be a function)
if task.task_type == "thumbnail_download":
return search.handle_thumbnail_download_task(task)
# HANDLE LOGIN
if task.task_type == "login":
return bkit_oauth.handle_login_task(task)
# HANDLE TOKEN REFRESH - most likely not needed anymore, TODO: remove
if task.task_type == "token_refresh":
return bkit_oauth.handle_token_refresh_task(task)
# HANDLE OAUTH LOGOUT
if task.task_type == "oauth2/logout":
return bkit_oauth.handle_logout_task(task)
# HANDLE CLIENT STATUS REPORT
if task.task_type == "client_status":
return client_lib.handle_client_status_task(task)
# HANDLE DISCLAIMER
if task.task_type == "disclaimer":
return disclaimer_op.handle_disclaimer_task(task)
# HANDLE CATEGORIES FETCH
if task.task_type == "categories_update":
return categories.handle_categories_task(task)
# HANDLE NOTIFICATIONS FETCH
if task.task_type == "notifications":
return comments_utils.handle_notifications_task(task)
# HANDLE VARIOUS COMMENTS TASKS
if task.task_type == "comments/get_comments":
return comments_utils.handle_get_comments_task(task)
if task.task_type == "comments/create_comment":
return comments_utils.handle_create_comment_task(task)
if task.task_type == "comments/feedback_comment":
return comments_utils.handle_feedback_comment_task(task)
if task.task_type == "comments/mark_comment_private":
return comments_utils.handle_mark_comment_private_task(task)
# HANDLE PROFILE
if task.task_type == "profiles/fetch_gravatar_image":
return search.handle_fetch_gravatar_task(task)
if task.task_type == "profiles/get_user_profile":
return search.handle_get_user_profile(task)
# HANDLE RATINGS
if task.task_type == "ratings/get_rating":
return ratings_utils.handle_get_rating_task(task)
if task.task_type == "ratings/get_ratings":
return ratings_utils.handle_get_ratings_task(task)
if task.task_type == "ratings/send_rating":
return ratings_utils.handle_send_rating_task(task)
# HANDLE BOOKMARKS
if task.task_type == "ratings/get_bookmarks":
return ratings_utils.handle_get_bookmarks_task(task)
# HANDLE NONBLOCKING_REQUEST
if task.task_type == "wrappers/nonblocking_request":
return utils.handle_nonblocking_request_task(task)
# BKCLIENTJS - Download from web
if task.task_type == "bkclientjs/get_asset":
return asset_bar_op.handle_bkclientjs_get_asset(task)
# HANDLE MESSAGE FROM CLIENT
if (
task.task_type == "message_from_daemon" # TODO: depracate message_from_daemon
or task.task_type == "message_from_client"
):
level = task.result.get("level", "INFO").upper()
duration = task.result.get("duration", 5)
destination = task.result.get("destination", "GUI")
if destination == "GUI":
return reports.add_report(task.message, duration, level)
if level == "INFO" or level == "VALIDATOR":
return bk_logger.info(task.message)
if level == "WARNING":
return bk_logger.warning(task.message)
if level == "ERROR":
return bk_logger.error(task.message)
@bpy.app.handlers.persistent
def check_timers_timer():
"""Checks if all timers are registered regularly. Prevents possible bugs from stopping the addon."""
if not bpy.app.timers.is_registered(tasks_queue.queue_worker):
bpy.app.timers.register(tasks_queue.queue_worker)
if not bpy.app.timers.is_registered(bg_blender.bg_update):
bpy.app.timers.register(bg_blender.bg_update)
if not bpy.app.timers.is_registered(client_communication_timer):
bpy.app.timers.register(client_communication_timer, persistent=True)
if not bpy.app.timers.is_registered(timer_image_cleanup):
bpy.app.timers.register(timer_image_cleanup, persistent=True, first_interval=60)
return 5.0
def on_startup_timer():
"""Run once on the startup of add-on (Blender start with enabled add-on, add-on enabled)."""
persistent_preferences.load_preferences_from_JSON()
addon_updater_ops.check_for_update_background()
utils.check_globaldir_permissions()
return None
def on_startup_client_online_timer():
"""Run once when Client is online after startup."""
if not global_vars.CLIENT_RUNNING:
return 1
preferences = bpy.context.preferences.addons[__package__].preferences
refresh_needed = bkit_oauth.ensure_token_refresh()
if refresh_needed: # called for new API token, lets wait for a while
return 1
if preferences.show_on_start:
search.search()
return
def register_timers():
"""Register all timers if add-on is not running in background (thumbnail rendering, upload, unpacking and also tests).
It registers check_timers_timer which registers all other periodic non-ending timers.
And individually it register all timers which are expected to end.
"""
if bpy.app.background:
return
# PERIODIC TIMERS
bpy.app.timers.register(
check_timers_timer, persistent=True
) # registers all other non-ending timers
# ONETIMERS
bpy.app.timers.register(on_startup_timer)
bpy.app.timers.register(on_startup_client_online_timer, first_interval=1)
bpy.app.timers.register(disclaimer_op.show_disclaimer_timer, first_interval=1)
def unregister_timers():
"""Unregister all timers at the very start of unregistration.
This prevents the timers being called before the unregistration finishes.
"""
if bpy.app.background:
return
if bpy.app.timers.is_registered(check_timers_timer):
bpy.app.timers.unregister(check_timers_timer)
if bpy.app.timers.is_registered(tasks_queue.queue_worker):
bpy.app.timers.unregister(tasks_queue.queue_worker)
if bpy.app.timers.is_registered(bg_blender.bg_update):
bpy.app.timers.unregister(bg_blender.bg_update)
if bpy.app.timers.is_registered(client_communication_timer):
bpy.app.timers.unregister(client_communication_timer)
if bpy.app.timers.is_registered(timer_image_cleanup):
bpy.app.timers.unregister(timer_image_cleanup)
if bpy.app.timers.is_registered(on_startup_timer):
bpy.app.timers.unregister(on_startup_timer)
if bpy.app.timers.is_registered(on_startup_client_online_timer):
bpy.app.timers.unregister(on_startup_client_online_timer)
if bpy.app.timers.is_registered(disclaimer_op.show_disclaimer_timer):
bpy.app.timers.unregister(disclaimer_op.show_disclaimer_timer)