1 # ##### BEGIN GPL LICENSE BLOCK #####
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; either version 2
6 # of the License, or (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software Foundation,
15 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 # ##### END GPL LICENSE BLOCK #####
19 from blenderkit
import paths
, utils
, categories
, ui
, colors
, bkit_oauth
, version_checker
, tasks_queue
, rerequests
, \
20 resolutions
, image_utils
23 from bpy
.app
.handlers
import persistent
25 from bpy
.props
import ( # TODO only keep the ones actually used when cleaning
34 from bpy
.types
import (
42 import requests
, os
, random
53 bk_logger
= logging
.getLogger('blenderkit')
59 def check_errors(rdata
):
60 if rdata
.get('statusCode') and int(rdata
.get('statusCode')) > 299:
62 if rdata
.get('detail') == 'Invalid token.':
63 user_preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
64 if user_preferences
.api_key
!= '':
65 if user_preferences
.enable_oauth
:
66 bkit_oauth
.refresh_token_thread()
67 return False, rdata
.get('detail')
68 return False, 'Use login panel to connect your profile.'
70 return False, rdata
.get('detail')
75 thumb_sml_download_threads
= {}
76 thumb_full_download_threads
= {}
79 rtips
= ['Click or drag model or material in scene to link/append ',
80 "Please rate responsively and plentifully. This helps us distribute rewards to the authors.",
81 "Click on brushes to link them into scene.",
82 "All materials and brushes are free.",
83 "Storage for public assets is unlimited.",
84 "Locked models are available if you subscribe to Full plan.",
85 "Login to upload your own models, materials or brushes.",
86 "Use 'A' key over asset bar to search assets by same author.",
87 "Use 'W' key over asset bar to open Authors webpage.", ]
90 def refresh_token_timer():
91 ''' this timer gets run every time the token needs refresh. It refreshes tokens and also categories.'''
92 utils
.p('refresh timer')
93 user_preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
95 categories
.load_categories()
97 return max(3600, user_preferences
.api_key_life
- 3600)
101 if not ad
.get('assetBaseId'):
103 ad
['assetBaseId'] = ad
['asset_base_id'] # this should stay ONLY for compatibility with older scenes
104 ad
['assetType'] = ad
['asset_type'] # this should stay ONLY for compatibility with older scenes
105 ad
['verificationStatus'] = ad
[
106 'verification_status'] # this should stay ONLY for compatibility with older scenes
108 ad
['author']['id'] = ad
['author_id'] # this should stay ONLY for compatibility with older scenes
109 ad
['canDownload'] = ad
['can_download'] # this should stay ONLY for compatibility with older scenes
110 except Exception as e
:
111 bk_logger
.error('BLenderKit failed to update older asset data')
115 def update_assets_data(): # updates assets data on scene load.
116 '''updates some properties that were changed on scenes with older assets.
117 The properties were mainly changed from snake_case to CamelCase to fit the data that is coming from the server.
126 for dtype
in datablocks
:
128 if block
.get('asset_data') != None:
129 update_ad(block
['asset_data'])
133 # 'assets rated',# assets rated stores only true/false, not asset data.
135 for s
in bpy
.data
.scenes
:
142 for asset_id
in d
.keys():
143 update_ad(d
[asset_id
])
144 # bpy.context.scene['assets used'][ad] = ad
147 def purge_search_results():
148 ''' clean up search results on save/load.'''
150 s
= bpy
.context
.scene
154 'search results orig',
156 asset_types
= ['model', 'material', 'scene', 'hdr', 'brush']
157 for at
in asset_types
:
158 sr_props
.append('bkit {at} search')
159 sr_props
.append('bkit {at} search orig')
160 for sr_prop
in sr_props
:
166 def scene_load(context
):
168 Loads categories , checks timers registration, and updates scene asset data.
169 Should (probably)also update asset data from server (after user consent)
171 wm
= bpy
.context
.window_manager
172 purge_search_results()
174 categories
.load_categories()
175 if not bpy
.app
.timers
.is_registered(refresh_token_timer
):
176 bpy
.app
.timers
.register(refresh_token_timer
, persistent
=True, first_interval
=36000)
180 def fetch_server_data():
181 ''' download categories , profile, and refresh token if needed.'''
182 if not bpy
.app
.background
:
183 user_preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
184 api_key
= user_preferences
.api_key
185 # Only refresh new type of tokens(by length), and only one hour before the token timeouts.
186 if user_preferences
.enable_oauth
and \
187 len(user_preferences
.api_key
) < 38 and len(user_preferences
.api_key
) > 0 and \
188 user_preferences
.api_key_timeout
< time
.time() + 3600:
189 bkit_oauth
.refresh_token_thread()
190 if api_key
!= '' and bpy
.context
.window_manager
.get('bkit profile') == None:
192 if bpy
.context
.window_manager
.get('bkit_categories') is None:
193 categories
.fetch_categories_thread(api_key
, force
= False)
200 def check_clipboard():
202 Checks clipboard for an exact string containing asset ID.
203 The string is generated on www.blenderkit.com as for example here:
204 https://www.blenderkit.com/get-blenderkit/54ff5c85-2c73-49e9-ba80-aec18616a408/
207 # clipboard monitoring to search assets from web
208 if platform
.system() != 'Linux':
209 global last_clipboard
210 if bpy
.context
.window_manager
.clipboard
!= last_clipboard
:
211 last_clipboard
= bpy
.context
.window_manager
.clipboard
212 instr
= 'asset_base_id:'
213 # first check if contains asset id, then asset type
214 if last_clipboard
[:len(instr
)] == instr
:
215 atstr
= 'asset_type:'
216 ati
= last_clipboard
.find(atstr
)
217 # this only checks if the asset_type keyword is there but let's the keywords update function do the parsing.
219 search_props
= utils
.get_search_props()
220 search_props
.search_keywords
= last_clipboard
221 # don't run search after this - assigning to keywords runs the search_update function.
226 needed to generate some extra data in the result(by now)
229 r - search result, also called asset_data
231 scene
= bpy
.context
.scene
233 # TODO remove this fix when filesSize is fixed.
234 # this is a temporary fix for too big numbers from the server.
236 # r['filesSize'] = int(r['filesSize'] / 1024)
238 # utils.p('asset with no files-size')
239 asset_type
= r
['assetType']
240 if len(r
['files']) > 0:#TODO remove this condition so all assets are parsed.
241 r
['available_resolutions'] = []
243 durl
, tname
, small_tname
= '', '', ''
245 if f
['fileType'] == 'thumbnail':
246 tname
= paths
.extract_filename_from_url(f
['fileThumbnailLarge'])
247 small_tname
= paths
.extract_filename_from_url(f
['fileThumbnail'])
248 allthumbs
.append(tname
) # TODO just first thumb is used now.
251 for i
, t
in enumerate(allthumbs
):
252 tdict
['thumbnail_%i'] = t
253 if f
['fileType'] == 'blend':
254 durl
= f
['downloadUrl'].split('?')[0]
255 # fname = paths.extract_filename_from_url(f['filePath'])
257 if f
['fileType'].find('resolution') > -1:
258 r
['available_resolutions'].append(resolutions
.resolutions
[f
['fileType']])
259 r
['max_resolution'] = 0
260 if r
['available_resolutions']: # should check only for non-empty sequences
261 r
['max_resolution'] = max(r
['available_resolutions'])
263 tooltip
= generate_tooltip(r
)
264 # for some reason, the id was still int on some occurances. investigate this.
265 r
['author']['id'] = str(r
['author']['id'])
267 # some helper props, but generally shouldn't be renaming/duplifiying original properties,
268 # so blender's data is same as on server.
269 asset_data
= {'thumbnail': tname
,
270 'thumbnail_small': small_tname
,
271 # 'thumbnails':allthumbs,
272 # 'download_url': durl, #made obsolete since files are stored in orig form.
274 # 'asset_base_id': r['assetBaseId'],#this should stay ONLY for compatibility with older scenes
276 # 'asset_type': r['assetType'], #this should stay ONLY for compatibility with older scenes
279 # 'can_download': r.get('canDownload', True),#this should stay ONLY for compatibility with older scenes
280 # 'verification_status': r['verificationStatus'],#this should stay ONLY for compatibility with older scenes
281 # 'author_id': r['author']['id'],#this should stay ONLY for compatibility with older scenes
282 # 'author': r['author']['firstName'] + ' ' + r['author']['lastName']
283 # 'description': r['description'],
285 asset_data
['downloaded'] = 0
287 # parse extra params needed for blender here
288 params
= utils
.params_to_dict(r
['parameters'])
290 if asset_type
== 'model':
291 if params
.get('boundBoxMinX') != None:
294 float(params
['boundBoxMinX']),
295 float(params
['boundBoxMinY']),
296 float(params
['boundBoxMinZ'])),
298 float(params
['boundBoxMaxX']),
299 float(params
['boundBoxMaxY']),
300 float(params
['boundBoxMaxZ']))
305 'bbox_min': (-.5, -.5, 0),
306 'bbox_max': (.5, .5, 1)
308 asset_data
.update(bbox
)
309 if asset_type
== 'material':
310 asset_data
['texture_size_meters'] = params
.get('textureSizeMeters', 1.0)
312 asset_data
.update(tdict
)
314 au
= scene
.get('assets used', {})
316 scene
['assets used'] = au
317 if r
['assetBaseId'] in au
.keys():
318 asset_data
['downloaded'] = 100
319 # transcribe all urls already fetched from the server
320 r_previous
= au
[r
['assetBaseId']]
321 if r_previous
.get('files'):
322 for f
in r_previous
['files']:
324 for f1
in r
['files']:
325 if f1
['fileType'] == f
['fileType']:
328 # attempt to switch to use original data gradually, since the parsing as itself should become obsolete.
333 # @bpy.app.handlers.persistent
335 # this makes a first search after opening blender. showing latest assets.
336 # utils.p('timer search')
339 preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
340 if first_time
and not bpy
.app
.background
: # first time
343 if preferences
.show_on_start
:
344 # TODO here it should check if there are some results, and only open assetbar if this is the case, not search.
345 # if bpy.context.window_manager.get('search results') is None:
347 # preferences.first_run = False
348 if preferences
.tips_on_start
:
349 utils
.get_largest_area()
350 ui
.update_ui_size(ui
.active_area_pointer
, ui
.active_region_pointer
)
351 ui
.add_report(text
='BlenderKit Tip: ' + random
.choice(rtips
), timeout
=12, color
=colors
.GREEN
)
354 # if preferences.first_run:
356 # preferences.first_run = False
360 global search_threads
361 if len(search_threads
) == 0:
363 # don't do anything while dragging - this could switch asset during drag, and make results list length different,
364 # causing a lot of throuble.
365 if bpy
.context
.scene
.blenderkitUI
.dragging
:
367 for thread
in search_threads
:
368 # TODO this doesn't check all processes when one gets removed,
369 # but most of the time only one is running anyway
370 if not thread
[0].is_alive():
371 search_threads
.remove(thread
) #
372 icons_dir
= thread
[1]
373 scene
= bpy
.context
.scene
374 # these 2 lines should update the previews enum and set the first result as active.
375 wm
= bpy
.context
.window_manager
376 asset_type
= thread
[2]
377 if asset_type
== 'model':
378 props
= scene
.blenderkit_models
379 # json_filepath = os.path.join(icons_dir, 'model_searchresult.json')
380 if asset_type
== 'scene':
381 props
= scene
.blenderkit_scene
382 # json_filepath = os.path.join(icons_dir, 'scene_searchresult.json')
383 if asset_type
== 'hdr':
384 props
= scene
.blenderkit_HDR
385 # json_filepath = os.path.join(icons_dir, 'scene_searchresult.json')
386 if asset_type
== 'material':
387 props
= scene
.blenderkit_mat
388 # json_filepath = os.path.join(icons_dir, 'material_searchresult.json')
389 if asset_type
== 'brush':
390 props
= scene
.blenderkit_brush
391 # json_filepath = os.path.join(icons_dir, 'brush_searchresult.json')
392 search_name
= f
'bkit {asset_type} search'
398 props
.report
= str(reports
)
401 rdata
= thread
[0].result
404 ok
, error
= check_errors(rdata
)
406 bpy
.ops
.object.run_assetbar_fix_context()
407 for r
in rdata
['results']:
408 asset_data
= parse_result(r
)
409 if asset_data
!= None:
410 result_field
.append(asset_data
)
412 # results = rdata['results']
413 wm
[search_name
] = result_field
414 wm
['search results'] = result_field
415 wm
[search_name
+ ' orig'] = copy
.deepcopy(rdata
)
416 wm
['search results orig'] = wm
[search_name
+ ' orig']
419 ui_props
= bpy
.context
.scene
.blenderkitUI
420 if len(result_field
) < ui_props
.scrolloffset
:
421 ui_props
.scrolloffset
= 0
422 props
.is_searching
= False
423 props
.search_error
= False
424 props
.report
= 'Found %i results. ' % (wm
['search results orig']['count'])
425 if len(wm
['search results']) == 0:
426 tasks_queue
.add_task((ui
.add_report
, ('No matching results found.',)))
428 bpy
.ops
.wm
.undo_push_context(message
='Get BlenderKit search')
431 bk_logger
.error(error
)
433 props
.search_error
= True
435 # print('finished search thread')
436 mt('preview loading finished')
442 scene
= bpy
.context
.scene
444 props
= scene
.blenderkitUI
445 directory
= paths
.get_temp_dir('%s_search' % props
.asset_type
.lower())
446 s
= bpy
.context
.scene
447 results
= bpy
.context
.window_manager
.get('search results')
449 if results
is not None:
455 tpath
= os
.path
.join(directory
, r
['thumbnail_small'])
456 if not r
['thumbnail_small']:
457 tpath
= paths
.get_addon_thumbnail_path('thumbnail_not_available.jpg')
459 if not os
.path
.exists(tpath
):
461 iname
= utils
.previmg_name(i
)
463 # if os.path.exists(tpath): # sometimes we are unlucky...
464 img
= bpy
.data
.images
.get(iname
)
467 img
= bpy
.data
.images
.load(tpath
)
469 elif img
.filepath
!= tpath
:
470 # had to add this check for autopacking files...
471 if img
.packed_file
is not None:
472 img
.unpack(method
='USE_ORIGINAL')
475 if r
['assetType'] == 'hdr':
476 # to display hdr thumbnails correctly, we use non-color, otherwise looks shifted
477 image_utils
.set_colorspace(img
, 'Non-Color')
479 image_utils
.set_colorspace(img
, 'sRGB')
482 # print('previews loaded')
485 # line splitting for longer texts...
486 def split_subs(text
, threshold
=40):
489 # temporarily disable this, to be able to do this in drawing code
492 text
= text
.replace('\r\n', '\n')
496 while len(text
) > threshold
:
497 # first handle if there's an \n line ending
498 i_rn
= text
.find('\n')
499 if 1 < i_rn
< threshold
:
501 text
= text
.replace('\n', '', 1)
503 i
= text
.rfind(' ', 0, threshold
)
504 i1
= text
.rfind(',', 0, threshold
)
505 i2
= text
.rfind('.', 0, threshold
)
509 lines
.append(text
[:i
])
515 def list_to_str(input):
517 for i
, text
in enumerate(input):
519 if i
< len(input) - 1:
524 def writeblock(t
, input, width
=40): # for longer texts
525 dlines
= split_subs(input, threshold
=width
)
526 for i
, l
in enumerate(dlines
):
531 def writeblockm(tooltip
, mdata
, key
='', pretext
=None, width
=40): # for longer texts
532 if mdata
.get(key
) == None:
536 if type(intext
) == list:
537 intext
= list_to_str(intext
)
538 if type(intext
) == float:
539 intext
= round(intext
, 3)
541 if intext
.rstrip() == '':
546 pretext
= pretext
+ ': '
547 text
= pretext
+ intext
548 dlines
= split_subs(text
, threshold
=width
)
549 for i
, l
in enumerate(dlines
):
550 tooltip
+= '%s\n' % l
555 def fmt_length(prop
):
556 prop
= str(round(prop
, 2)) + 'm'
560 def has(mdata
, prop
):
561 if mdata
.get(prop
) is not None and mdata
[prop
] is not None and mdata
[prop
] is not False:
567 def generate_tooltip(mdata
):
569 if type(mdata
['parameters']) == list:
570 mparams
= utils
.params_to_dict(mdata
['parameters'])
572 mparams
= mdata
['parameters']
574 t
= writeblock(t
, mdata
['displayName'], width
=col_w
)
577 t
= writeblockm(t
, mdata
, key
='description', pretext
='', width
=col_w
)
578 if mdata
['description'] != '':
581 bools
= (('rig', None), ('animated', None), ('manifold', 'non-manifold'), ('scene', None), ('simulation', None),
584 if mparams
.get(b
[0]):
585 mdata
['tags'].append(b
[0])
587 mdata
['tags'].append(b
[1])
589 bools_data
= ('adult',)
591 if mdata
.get(b
) and mdata
[b
]:
592 mdata
['tags'].append(b
)
593 t
= writeblockm(t
, mparams
, key
='designer', pretext
='designer', width
=col_w
)
594 t
= writeblockm(t
, mparams
, key
='manufacturer', pretext
='manufacturer', width
=col_w
)
595 t
= writeblockm(t
, mparams
, key
='designCollection', pretext
='design collection', width
=col_w
)
597 # t = writeblockm(t, mparams, key='engines', pretext='engine', width = col_w)
598 # t = writeblockm(t, mparams, key='model_style', pretext='style', width = col_w)
599 # t = writeblockm(t, mparams, key='material_style', pretext='style', width = col_w)
600 # t = writeblockm(t, mdata, key='tags', width = col_w)
601 # t = writeblockm(t, mparams, key='condition', pretext='condition', width = col_w)
602 # t = writeblockm(t, mparams, key='productionLevel', pretext='production level', width = col_w)
603 if has(mdata
, 'purePbr'):
604 t
= writeblockm(t
, mparams
, key
='pbrType', pretext
='pbr', width
=col_w
)
606 t
= writeblockm(t
, mparams
, key
='designYear', pretext
='design year', width
=col_w
)
608 if has(mparams
, 'dimensionX'):
609 t
+= 'size: %s, %s, %s\n' % (fmt_length(mparams
['dimensionX']),
610 fmt_length(mparams
['dimensionY']),
611 fmt_length(mparams
['dimensionZ']))
612 if has(mparams
, 'faceCount'):
613 t
+= 'face count: %s, render: %s\n' % (mparams
['faceCount'], mparams
['faceCountRender'])
615 # write files size - this doesn't reflect true file size, since files size is computed from all asset files, including resolutions.
616 if mdata
.get('filesSize'):
617 fs
= utils
.files_size_to_text(mdata
['filesSize'])
618 t
+= f
'files size: {fs}\n'
620 # t = writeblockm(t, mparams, key='meshPolyType', pretext='mesh type', width = col_w)
621 # t = writeblockm(t, mparams, key='objectCount', pretext='nubmber of objects', width = col_w)
623 # t = writeblockm(t, mparams, key='materials', width = col_w)
624 # t = writeblockm(t, mparams, key='modifiers', width = col_w)
625 # t = writeblockm(t, mparams, key='shaders', width = col_w)
627 if has(mparams
, 'textureSizeMeters'):
628 t
+= 'texture size: %s\n' % fmt_length(mparams
['textureSizeMeters'])
630 if has(mparams
, 'textureResolutionMax') and mparams
['textureResolutionMax'] > 0:
631 if not mparams
.get('textureResolutionMin'): # for HDR's
632 t
= writeblockm(t
, mparams
, key
='textureResolutionMax', pretext
='Resolution', width
=col_w
)
633 elif mparams
.get('textureResolutionMin') == mparams
['textureResolutionMax']:
634 t
= writeblockm(t
, mparams
, key
='textureResolutionMin', pretext
='texture resolution', width
=col_w
)
636 t
+= 'tex resolution: %i - %i\n' % (mparams
.get('textureResolutionMin'), mparams
['textureResolutionMax'])
638 if has(mparams
, 'thumbnailScale'):
639 t
= writeblockm(t
, mparams
, key
='thumbnailScale', pretext
='preview scale', width
=col_w
)
641 # t += 'uv: %s\n' % mdata['uv']
643 t
= writeblockm(t
, mdata
, key
='license', width
=col_w
)
645 fs
= mdata
.get('files')
647 if utils
.profile_is_validator():
649 resolutions
= 'resolutions:'
651 if f
['fileType'].find('resolution') > -1:
652 resolutions
+= f
['fileType'][11:] + ' '
656 t
= writeblockm(t
, mdata
, key
='isFree', width
=col_w
)
660 if f
['fileType'].find('resolution') > -1:
661 t
+= 'Asset has lower resolutions available\n'
663 # generator is for both upload preview and search, this is only after search
664 # if mdata.get('versionNumber'):
665 # # t = writeblockm(t, mdata, key='versionNumber', pretext='version', width = col_w)
666 # a_id = mdata['author'].get('id')
668 # adata = bpy.context.window_manager['bkit authors'].get(str(a_id))
670 # t += generate_author_textblock(adata)
673 rc
= mdata
.get('ratingsCount')
674 if utils
.profile_is_validator() and rc
:
677 rcount
= min(rc
['quality'], rc
['workingHours'])
681 t
+= f
"Please rate this asset, \nit doesn't have enough ratings.\n"
683 t
+= f
"Quality rating: {int(mdata['ratingsAverage']['quality']) * '*'}\n"
684 t
+= f
"Hours saved rating: {int(mdata['ratingsAverage']['workingHours'])}\n"
685 if utils
.profile_is_validator():
686 t
+= f
"Score: {int(mdata['score'])}\n"
688 t
+= f
"Ratings count {rc['quality']}*/{rc['workingHours']}wh value " \
689 f
"{mdata['ratingsAverage']['quality']}*/{mdata['ratingsAverage']['workingHours']}wh\n"
690 if len(t
.split('\n')) < 11:
692 t
+= get_random_tip(mdata
)
697 def get_random_tip(mdata
):
700 tip
= 'Tip: ' + random
.choice(rtips
)
701 t
= writeblock(t
, tip
)
703 # at = mdata['assetType']
704 # if at == 'brush' or at == 'texture':
705 # t += 'click to link %s' % mdata['assetType']
706 # if at == 'model' or at == 'material':
707 # tips = ['Click or drag in scene to link/append %s' % mdata['assetType'],
708 # "'A' key to search assets by same author",
709 # "'W' key to open Authors webpage",
711 # tip = 'Tip: ' + random.choice(tips)
712 # t = writeblock(t, tip)
716 def generate_author_textblock(adata
):
719 if adata
not in (None, ''):
721 if len(adata
['firstName'] + adata
['lastName']) > 0:
723 t
+= '%s %s\n' % (adata
['firstName'], adata
['lastName'])
725 if adata
.get('aboutMeUrl') is not None:
726 t
= writeblockm(t
, adata
, key
='aboutMeUrl', pretext
='', width
=col_w
)
728 if adata
.get('aboutMe') is not None:
729 t
= writeblockm(t
, adata
, key
='aboutMe', pretext
='', width
=col_w
)
734 def get_items_models(self
, context
):
735 global search_items_models
736 return search_items_models
739 def get_items_brushes(self
, context
):
740 global search_items_brushes
741 return search_items_brushes
744 def get_items_materials(self
, context
):
745 global search_items_materials
746 return search_items_materials
749 def get_items_textures(self
, context
):
750 global search_items_textures
751 return search_items_textures
754 class ThumbDownloader(threading
.Thread
):
757 def __init__(self
, url
, path
):
758 super(ThumbDownloader
, self
).__init
__()
761 self
._stop
_event
= threading
.Event()
764 self
._stop
_event
.set()
767 return self
._stop
_event
.is_set()
770 r
= rerequests
.get(self
.url
, stream
=False)
771 if r
.status_code
== 200:
772 with
open(self
.path
, 'wb') as f
:
774 # ORIGINALLY WE DOWNLOADED THUMBNAILS AS STREAM, BUT THIS WAS TOO SLOW.
775 # with open(path, 'wb') as f:
776 # for chunk in r.iter_content(1048576*4):
780 def write_gravatar(a_id
, gravatar_path
):
782 Write down gravatar path, as a result of thread-based gravatar image download.
783 This should happen on timer in queue.
785 # print('write author', a_id, type(a_id))
786 authors
= bpy
.context
.window_manager
['bkit authors']
787 if authors
.get(a_id
) is not None:
788 adata
= authors
.get(a_id
)
789 adata
['gravatarImg'] = gravatar_path
792 def fetch_gravatar(adata
):
793 # utils.p('fetch gravatar')
794 if adata
.get('gravatarHash') is not None:
795 gravatar_path
= paths
.get_temp_dir(subdir
='bkit_g/') + adata
['gravatarHash'] + '.jpg'
797 if os
.path
.exists(gravatar_path
):
798 tasks_queue
.add_task((write_gravatar
, (adata
['id'], gravatar_path
)))
801 url
= "https://www.gravatar.com/avatar/" + adata
['gravatarHash'] + '?d=404'
802 r
= rerequests
.get(url
, stream
=False)
803 if r
.status_code
== 200:
804 with
open(gravatar_path
, 'wb') as f
:
806 tasks_queue
.add_task((write_gravatar
, (adata
['id'], gravatar_path
)))
807 elif r
.status_code
== '404':
808 adata
['gravatarHash'] = None
809 utils
.p('gravatar for author not available.')
812 fetching_gravatars
= {}
816 ''' Writes author info (now from search results) and fetches gravatar if needed.'''
817 global fetching_gravatars
819 a_id
= str(r
['author']['id'])
820 preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
821 authors
= bpy
.context
.window_manager
.get('bkit authors', {})
823 bpy
.context
.window_manager
['bkit authors'] = authors
824 a
= authors
.get(a_id
)
825 if a
is None: # or a is '' or (a.get('gravatarHash') is not None and a.get('gravatarImg') is None):
828 a
['tooltip'] = generate_author_textblock(a
)
831 if fetching_gravatars
.get(a
['id']) is None:
832 fetching_gravatars
[a
['id']] = True
834 thread
= threading
.Thread(target
=fetch_gravatar
, args
=(a
.copy(),), daemon
=True)
839 def write_profile(adata
):
840 utils
.p('writing profile information')
842 # we have to convert to MiB here, numbers too big for python int type
843 if user
.get('sumAssetFilesSize') is not None:
844 user
['sumAssetFilesSize'] /= (1024 * 1024)
845 if user
.get('sumPrivateAssetFilesSize') is not None:
846 user
['sumPrivateAssetFilesSize'] /= (1024 * 1024)
847 if user
.get('remainingPrivateQuota') is not None:
848 user
['remainingPrivateQuota'] /= (1024 * 1024)
850 if adata
.get('canEditAllAssets') is True:
851 user
['exmenu'] = True
853 user
['exmenu'] = False
855 bpy
.context
.window_manager
['bkit profile'] = adata
858 def request_profile(api_key
):
859 a_url
= paths
.get_api_url() + 'me/'
860 headers
= utils
.get_headers(api_key
)
861 r
= rerequests
.get(a_url
, headers
=headers
)
863 if adata
.get('user') is None:
865 utils
.p('getting profile failed')
870 def fetch_profile(api_key
):
871 utils
.p('fetch profile')
873 adata
= request_profile(api_key
)
874 if adata
is not None:
875 tasks_queue
.add_task((write_profile
, (adata
,)))
876 except Exception as e
:
881 preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
882 a
= bpy
.context
.window_manager
.get('bkit profile')
883 thread
= threading
.Thread(target
=fetch_profile
, args
=(preferences
.api_key
,), daemon
=True)
888 class Searcher(threading
.Thread
):
891 def __init__(self
, query
, params
, orig_result
):
892 super(Searcher
, self
).__init
__()
895 self
._stop
_event
= threading
.Event()
896 self
.result
= orig_result
899 self
._stop
_event
.set()
902 return self
._stop
_event
.is_set()
904 def query_to_url(self
):
907 # build a new request
908 url
= paths
.get_api_url() + 'search/'
910 # build request manually
911 # TODO use real queries
912 requeststring
= '?query='
914 if query
.get('query') not in ('', None):
915 requeststring
+= query
['query'].lower()
916 for i
, q
in enumerate(query
):
919 requeststring
+= q
+ ':' + str(query
[q
]).lower()
921 # result ordering: _score - relevance, score - BlenderKit score
923 if params
['free_first']:
924 order
= ['-is_free', ]
925 if query
.get('query') is None and query
.get('category_subtree') == None:
926 # assumes no keywords and no category, thus an empty search that is triggered on start.
927 # orders by last core file upload
928 if query
.get('verification_status') == 'uploaded':
929 # for validators, sort uploaded from oldest
930 order
.append('created')
932 order
.append('-last_upload')
933 elif query
.get('author_id') is not None and utils
.profile_is_validator():
935 order
.append('-created')
937 if query
.get('category_subtree') is not None:
938 order
.append('-score,_score')
940 order
.append('_score')
941 requeststring
+= '+order:' + ','.join(order
)
943 requeststring
+= '&addon_version=%s' % params
['addon_version']
944 if params
.get('scene_uuid') is not None:
945 requeststring
+= '&scene_uuid=%s' % params
['scene_uuid']
946 # print('params', params)
947 urlquery
= url
+ requeststring
957 mt('search thread started')
958 tempdir
= paths
.get_temp_dir('%s_search' % query
['asset_type'])
959 # json_filepath = os.path.join(tempdir, '%s_searchresult.json' % query['asset_type'])
961 headers
= utils
.get_headers(params
['api_key'])
964 rdata
['results'] = []
966 if params
['get_next']:
967 urlquery
= self
.result
['next']
968 if not params
['get_next']:
969 urlquery
= self
.query_to_url()
973 r
= rerequests
.get(urlquery
, headers
=headers
) # , params = rparameters)
977 except requests
.exceptions
.RequestException
as e
:
982 mt('search response is back ')
985 except Exception as e
:
990 if not rdata
.get('results'):
992 # if the result was converted to json and didn't return results,
993 # it means it's a server error that has a clear message.
994 # That's why it gets processed in the update timer, where it can be passed in messages to user.
997 # print('number of results: ', len(rdata.get('results', [])))
999 utils
.p('stopping search : ' + str(query
))
1002 mt('search finished')
1005 thumb_small_urls
= []
1006 thumb_small_filepaths
= []
1007 thumb_full_urls
= []
1008 thumb_full_filepaths
= []
1010 for d
in rdata
.get('results', []):
1014 for f
in d
['files']:
1015 # TODO move validation of published assets to server, too manmy checks here.
1016 if f
['fileType'] == 'thumbnail' and f
['fileThumbnail'] != None and f
['fileThumbnailLarge'] != None:
1017 if f
['fileThumbnail'] == None:
1018 f
['fileThumbnail'] = 'NONE'
1019 if f
['fileThumbnailLarge'] == None:
1020 f
['fileThumbnailLarge'] = 'NONE'
1022 thumb_small_urls
.append(f
['fileThumbnail'])
1023 thumb_full_urls
.append(f
['fileThumbnailLarge'])
1025 imgname
= paths
.extract_filename_from_url(f
['fileThumbnail'])
1026 imgpath
= os
.path
.join(tempdir
, imgname
)
1027 thumb_small_filepaths
.append(imgpath
)
1029 imgname
= paths
.extract_filename_from_url(f
['fileThumbnailLarge'])
1030 imgpath
= os
.path
.join(tempdir
, imgname
)
1031 thumb_full_filepaths
.append(imgpath
)
1033 sml_thbs
= zip(thumb_small_filepaths
, thumb_small_urls
)
1034 full_thbs
= zip(thumb_full_filepaths
, thumb_full_urls
)
1036 # we save here because a missing thumbnail check is in the previous loop
1037 # we can also prepend previous results. These have downloaded thumbnails already...
1038 if params
['get_next']:
1039 rdata
['results'][0:0] = self
.result
['results']
1041 # with open(json_filepath, 'w', encoding = 'utf-8') as outfile:
1042 # json.dump(rdata, outfile, ensure_ascii=False, indent=4)
1044 killthreads_sml
= []
1045 for k
in thumb_sml_download_threads
.keys():
1046 if k
not in thumb_small_filepaths
:
1047 killthreads_sml
.append(k
) # do actual killing here?
1049 killthreads_full
= []
1050 for k
in thumb_full_download_threads
.keys():
1051 if k
not in thumb_full_filepaths
:
1052 killthreads_full
.append(k
) # do actual killing here?
1053 # TODO do the killing/ stopping here! remember threads might have finished inbetween!
1056 utils
.p('stopping search : ' + str(query
))
1059 # this loop handles downloading of small thumbnails
1060 for imgpath
, url
in sml_thbs
:
1061 if imgpath
not in thumb_sml_download_threads
and not os
.path
.exists(imgpath
):
1062 thread
= ThumbDownloader(url
, imgpath
)
1063 # thread = threading.Thread(target=download_thumbnail, args=([url, imgpath]),
1066 thumb_sml_download_threads
[imgpath
] = thread
1067 # threads.append(thread)
1069 if len(thumb_sml_download_threads
) > maxthreads
:
1070 while len(thumb_sml_download_threads
) > maxthreads
:
1071 threads_copy
= thumb_sml_download_threads
.copy() # because for loop can erase some of the items.
1072 for tk
, thread
in threads_copy
.items():
1073 if not thread
.is_alive():
1076 del (thumb_sml_download_threads
[tk
])
1077 # utils.p('fetched thumbnail ', i)
1080 utils
.p('stopping search : ' + str(query
))
1083 while len(thumb_sml_download_threads
) > 0:
1084 threads_copy
= thumb_sml_download_threads
.copy() # because for loop can erase some of the items.
1085 for tk
, thread
in threads_copy
.items():
1086 if not thread
.is_alive():
1088 del (thumb_sml_download_threads
[tk
])
1092 utils
.p('stopping search : ' + str(query
))
1095 # start downloading full thumbs in the end
1096 for imgpath
, url
in full_thbs
:
1097 if imgpath
not in thumb_full_download_threads
and not os
.path
.exists(imgpath
):
1098 thread
= ThumbDownloader(url
, imgpath
)
1099 # thread = threading.Thread(target=download_thumbnail, args=([url, imgpath]),
1102 thumb_full_download_threads
[imgpath
] = thread
1103 mt('thumbnails finished')
1106 def build_query_common(query
, props
):
1107 '''add shared parameters to query'''
1109 if props
.search_keywords
!= '':
1110 query_common
["query"] = props
.search_keywords
1112 if props
.search_verification_status
!= 'ALL':
1113 query_common
['verification_status'] = props
.search_verification_status
.lower()
1115 if props
.unrated_only
:
1116 query
["quality_count"] = 0
1118 if props
.search_file_size
:
1119 query_common
["files_size_gte"] = props
.search_file_size_min
* 1024 * 1024
1120 query_common
["files_size_lte"] = props
.search_file_size_max
* 1024 * 1024
1122 query
.update(query_common
)
1125 def build_query_model():
1126 '''use all search input to request results from server'''
1128 props
= bpy
.context
.scene
.blenderkit_models
1130 "asset_type": 'model',
1131 # "engine": props.search_engine,
1132 # "adult": props.search_adult,
1134 if props
.search_style
!= 'ANY':
1135 if props
.search_style
!= 'OTHER':
1136 query
["model_style"] = props
.search_style
1138 query
["model_style"] = props
.search_style_other
1140 # the 'free_only' parametr gets moved to the search command and is used for ordering the assets as free first
1141 # if props.free_only:
1142 # query["is_free"] = True
1144 # if props.search_advanced:
1145 if props
.search_condition
!= 'UNSPECIFIED':
1146 query
["condition"] = props
.search_condition
1147 if props
.search_design_year
:
1148 query
["designYear_gte"] = props
.search_design_year_min
1149 query
["designYear_lte"] = props
.search_design_year_max
1150 if props
.search_polycount
:
1151 query
["faceCount_gte"] = props
.search_polycount_min
1152 query
["faceCount_lte"] = props
.search_polycount_max
1153 if props
.search_texture_resolution
:
1154 query
["textureResolutionMax_gte"] = props
.search_texture_resolution_min
1155 query
["textureResolutionMax_lte"] = props
.search_texture_resolution_max
1157 build_query_common(query
, props
)
1162 def build_query_scene():
1163 '''use all search input to request results from server'''
1165 props
= bpy
.context
.scene
.blenderkit_scene
1167 "asset_type": 'scene',
1168 # "engine": props.search_engine,
1169 # "adult": props.search_adult,
1171 build_query_common(query
, props
)
1175 def build_query_HDR():
1176 '''use all search input to request results from server'''
1178 props
= bpy
.context
.scene
.blenderkit_HDR
1180 "asset_type": 'hdr',
1181 # "engine": props.search_engine,
1182 # "adult": props.search_adult,
1184 build_query_common(query
, props
)
1188 def build_query_material():
1189 props
= bpy
.context
.scene
.blenderkit_mat
1191 "asset_type": 'material',
1194 # if props.search_engine == 'NONE':
1195 # query["engine"] = ''
1196 # if props.search_engine != 'OTHER':
1197 # query["engine"] = props.search_engine
1199 # query["engine"] = props.search_engine_other
1200 if props
.search_style
!= 'ANY':
1201 if props
.search_style
!= 'OTHER':
1202 query
["style"] = props
.search_style
1204 query
["style"] = props
.search_style_other
1205 if props
.search_procedural
== 'TEXTURE_BASED':
1206 # todo this procedural hack should be replaced with the parameter
1207 query
["textureResolutionMax_gte"] = 0
1208 # query["procedural"] = False
1209 if props
.search_texture_resolution
:
1210 query
["textureResolutionMax_gte"] = props
.search_texture_resolution_min
1211 query
["textureResolutionMax_lte"] = props
.search_texture_resolution_max
1215 elif props
.search_procedural
== "PROCEDURAL":
1216 # todo this procedural hack should be replaced with the parameter
1217 query
["files_size_lte"] = 1024 * 1024
1218 # query["procedural"] = True
1220 build_query_common(query
, props
)
1225 def build_query_texture():
1226 props
= bpy
.context
.scene
.blenderkit_tex
1228 "asset_type": 'texture',
1232 if props
.search_style
!= 'ANY':
1233 if props
.search_style
!= 'OTHER':
1234 query
["search_style"] = props
.search_style
1236 query
["search_style"] = props
.search_style_other
1238 build_query_common(query
, props
)
1243 def build_query_brush():
1244 props
= bpy
.context
.scene
.blenderkit_brush
1247 if bpy
.context
.sculpt_object
is not None:
1248 brush_type
= 'sculpt'
1250 elif bpy
.context
.image_paint_object
: # could be just else, but for future p
1251 brush_type
= 'texture_paint'
1254 "asset_type": 'brush',
1259 build_query_common(query
, props
)
1265 global search_start_time
, prev_time
1266 alltime
= time
.time() - search_start_time
1267 since_last
= time
.time() - prev_time
1268 prev_time
= time
.time()
1269 utils
.p(text
, alltime
, since_last
)
1272 def add_search_process(query
, params
, orig_result
):
1273 global search_threads
1275 while (len(search_threads
) > 0):
1276 old_thread
= search_threads
.pop(0)
1277 old_thread
[0].stop()
1278 # TODO CARE HERE FOR ALSO KILLING THE Thumbnail THREADS.?
1279 # AT LEAST NOW SEARCH DONE FIRST WON'T REWRITE AN OLDER ONE
1280 tempdir
= paths
.get_temp_dir('%s_search' % query
['asset_type'])
1281 thread
= Searcher(query
, params
, orig_result
)
1284 search_threads
.append([thread
, tempdir
, query
['asset_type'], {}]) # 4th field is for results
1286 mt('search thread started')
1289 def get_search_simple(parameters
, filepath
=None, page_size
=100, max_results
=100000000, api_key
=''):
1291 Searches and returns the
1296 parameters - dict of blenderkit elastic parameters
1297 filepath - a file to save the results. If None, results are returned
1298 page_size - page size for retrieved results
1299 max_results - max results of the search
1300 api_key - BlenderKit api key
1304 Returns search results as a list, and optionally saves to filepath
1307 headers
= utils
.get_headers(api_key
)
1308 url
= paths
.get_api_url() + 'search/'
1309 requeststring
= url
+ '?query='
1310 for p
in parameters
.keys():
1311 requeststring
+= f
'+{p}:{parameters[p]}'
1313 requeststring
+= '&page_size=' + str(page_size
)
1314 bk_logger
.debug(requeststring
)
1315 response
= rerequests
.get(requeststring
, headers
=headers
) # , params = rparameters)
1317 search_results
= response
.json()
1320 results
.extend(search_results
['results'])
1322 page_count
= math
.ceil(search_results
['count'] / page_size
)
1323 while search_results
.get('next') and len(results
) < max_results
:
1324 bk_logger
.info(f
'getting page {page_index} , total pages {page_count}')
1325 response
= rerequests
.get(search_results
['next'], headers
=headers
) # , params = rparameters)
1326 search_results
= response
.json()
1327 # print(search_results)
1328 results
.extend(search_results
['results'])
1334 with
open(filepath
, 'w', encoding
='utf-8') as s
:
1335 json
.dump(results
, s
, ensure_ascii
=False, indent
=4)
1336 bk_logger
.info(f
'retrieved {len(results)} assets from elastic search')
1340 def search(category
='', get_next
=False, author_id
=''):
1341 ''' initialize searching'''
1342 global search_start_time
1343 user_preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
1344 search_start_time
= time
.time()
1346 scene
= bpy
.context
.scene
1347 ui_props
= scene
.blenderkitUI
1349 if ui_props
.asset_type
== 'MODEL':
1350 if not hasattr(scene
, 'blenderkit'):
1352 props
= scene
.blenderkit_models
1353 query
= build_query_model()
1355 if ui_props
.asset_type
== 'SCENE':
1356 if not hasattr(scene
, 'blenderkit_scene'):
1358 props
= scene
.blenderkit_scene
1359 query
= build_query_scene()
1361 if ui_props
.asset_type
== 'HDR':
1362 if not hasattr(scene
, 'blenderkit_HDR'):
1364 props
= scene
.blenderkit_HDR
1365 query
= build_query_HDR()
1367 if ui_props
.asset_type
== 'MATERIAL':
1368 if not hasattr(scene
, 'blenderkit_mat'):
1371 props
= scene
.blenderkit_mat
1372 query
= build_query_material()
1374 if ui_props
.asset_type
== 'TEXTURE':
1375 if not hasattr(scene
, 'blenderkit_tex'):
1377 # props = scene.blenderkit_tex
1378 # query = build_query_texture()
1380 if ui_props
.asset_type
== 'BRUSH':
1381 if not hasattr(scene
, 'blenderkit_brush'):
1383 props
= scene
.blenderkit_brush
1384 query
= build_query_brush()
1386 # it's possible get_net was requested more than once.
1387 if props
.is_searching
and get_next
== True:
1391 if utils
.profile_is_validator():
1392 query
['category'] = category
1394 query
['category_subtree'] = category
1397 query
['author_id'] = author_id
1399 elif props
.own_only
:
1400 # if user searches for [another] author, 'only my assets' is invalid. that's why in elif.
1401 profile
= bpy
.context
.window_manager
.get('bkit profile')
1402 if profile
is not None:
1403 query
['author_id'] = str(profile
['user']['id'])
1405 # utils.p('searching')
1406 props
.is_searching
= True
1409 'scene_uuid': bpy
.context
.scene
.get('uuid', None),
1410 'addon_version': version_checker
.get_addon_version(),
1411 'api_key': user_preferences
.api_key
,
1412 'get_next': get_next
,
1413 'free_first': props
.free_only
1417 # query['keywords'] += '+is_free:true'
1418 orig_results
= bpy
.context
.window_manager
.get(f
'bkit {ui_props.asset_type.lower()} search orig', {})
1419 if orig_results
!= {}:
1420 # ensure it's a copy in dict for what we are passing to thread:
1421 orig_results
= orig_results
.to_dict()
1422 add_search_process(query
, params
, orig_results
)
1423 tasks_queue
.add_task((ui
.add_report
, ('BlenderKit searching....', 2)))
1425 props
.report
= 'BlenderKit searching....'
1428 def search_update(self
, context
):
1429 utils
.p('search updater')
1430 # if self.search_keywords != '':
1431 ui_props
= bpy
.context
.scene
.blenderkitUI
1432 if ui_props
.down_up
!= 'SEARCH':
1433 ui_props
.down_up
= 'SEARCH'
1435 # here we tweak the input if it comes form the clipboard. we need to get rid of asset type and set it in UI
1436 sprops
= utils
.get_search_props()
1437 instr
= 'asset_base_id:'
1438 atstr
= 'asset_type:'
1439 kwds
= sprops
.search_keywords
1440 idi
= kwds
.find(instr
)
1441 ati
= kwds
.find(atstr
)
1442 # if the asset type already isn't there it means this update function
1443 # was triggered by it's last iteration and needs to cancel
1445 at
= kwds
[ati
:].lower()
1446 # uncertain length of the remaining string - find as better method to check the presence of asset type
1447 if at
.find('model') > -1:
1448 ui_props
.asset_type
= 'MODEL'
1449 elif at
.find('material') > -1:
1450 ui_props
.asset_type
= 'MATERIAL'
1451 elif at
.find('brush') > -1:
1452 ui_props
.asset_type
= 'BRUSH'
1453 elif at
.find('scene') > -1:
1454 ui_props
.asset_type
= 'SCENE'
1455 elif at
.find('hdr') > -1:
1456 ui_props
.asset_type
= 'HDR'
1457 # now we trim the input copypaste by anything extra that is there,
1458 # this is also a way for this function to recognize that it already has parsed the clipboard
1459 # the search props can have changed and this needs to transfer the data to the other field
1460 # this complex behaviour is here for the case where the user needs to paste manually into blender?
1461 sprops
= utils
.get_search_props()
1462 sprops
.search_keywords
= kwds
[:ati
].rstrip()
1463 # return here since writing into search keywords triggers this update function once more.
1469 class SearchOperator(Operator
):
1471 bl_idname
= "view3d.blenderkit_search"
1472 bl_label
= "BlenderKit asset search"
1473 bl_description
= "Search online for assets"
1474 bl_options
= {'REGISTER', 'UNDO', 'INTERNAL'}
1475 own
: BoolProperty(name
="own assets only",
1476 description
="Find all own assets",
1479 category
: StringProperty(
1481 description
="search only subtree of this category",
1483 options
={'SKIP_SAVE'}
1486 author_id
: StringProperty(
1488 description
="Author ID - search only assets by this author",
1490 options
={'SKIP_SAVE'}
1493 get_next
: BoolProperty(name
="next page",
1494 description
="get next page from previous search",
1496 options
={'SKIP_SAVE'}
1499 keywords
: StringProperty(
1501 description
="Keywords",
1503 options
={'SKIP_SAVE'}
1507 def poll(cls
, context
):
1510 def execute(self
, context
):
1511 # TODO ; this should all get transferred to properties of the search operator, so sprops don't have to be fetched here at all.
1512 sprops
= utils
.get_search_props()
1513 if self
.author_id
!= '':
1514 sprops
.search_keywords
= ''
1515 if self
.keywords
!= '':
1516 sprops
.search_keywords
= self
.keywords
1518 search(category
=self
.category
, get_next
=self
.get_next
, author_id
=self
.author_id
)
1519 # bpy.ops.view3d.blenderkit_asset_bar()
1529 def register_search():
1530 bpy
.app
.handlers
.load_post
.append(scene_load
)
1533 bpy
.utils
.register_class(c
)
1535 user_preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
1536 if user_preferences
.use_timers
:
1537 bpy
.app
.timers
.register(timer_update
)
1539 categories
.load_categories()
1542 def unregister_search():
1543 bpy
.app
.handlers
.load_post
.remove(scene_load
)
1546 bpy
.utils
.unregister_class(c
)
1548 if bpy
.app
.timers
.is_registered(timer_update
):
1549 bpy
.app
.timers
.unregister(timer_update
)