From 150054d19ced0eb8ea30a5a8299976a040def753 Mon Sep 17 00:00:00 2001 From: Swann Date: Sun, 20 Sep 2020 19:53:51 +0200 Subject: [PATCH 1/9] feat: generic file replication ground work --- multi_user/bl_types/__init__.py | 3 +- multi_user/bl_types/bl_file.py | 122 ++++++++++++++++++++++++++++++++ multi_user/bl_types/bl_font.py | 47 ++++++------ 3 files changed, 147 insertions(+), 25 deletions(-) create mode 100644 multi_user/bl_types/bl_file.py diff --git a/multi_user/bl_types/__init__.py b/multi_user/bl_types/__init__.py index ad35fe8..add7058 100644 --- a/multi_user/bl_types/__init__.py +++ b/multi_user/bl_types/__init__.py @@ -36,7 +36,8 @@ __all__ = [ 'bl_lightprobe', 'bl_speaker', 'bl_font', - 'bl_sound' + 'bl_sound', + 'bl_file' ] # Order here defines execution order from . import * diff --git a/multi_user/bl_types/bl_file.py b/multi_user/bl_types/bl_file.py new file mode 100644 index 0000000..36c2348 --- /dev/null +++ b/multi_user/bl_types/bl_file.py @@ -0,0 +1,122 @@ +# ##### 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 3 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, see . +# +# ##### END GPL LICENSE BLOCK ##### + + +import logging +import os +import sys +from pathlib import Path + +import bpy +import mathutils +from replication.constants import DIFF_BINARY, UP +from replication.data import ReplicatedDatablock + +from .. import utils +from .dump_anything import Dumper, Loader + + +def get_filepath(filename): + """ + Construct the local filepath + """ + return os.path.join( + utils.get_preferences().cache_directory, + filename + ) + + +class BlFile(ReplicatedDatablock): + bl_id = 'file' + bl_name = "file" + bl_class = Path + bl_delay_refresh = 1 + bl_delay_apply = 1 + bl_automatic_push = True + bl_check_common = False + bl_icon = 'FILE' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.instance = kwargs.get('instance', None) + # TODO: handle packed_file + # TODO: ensure absolute path + # TODO: ensure file exist + logging.info(self.instance) + + self.preferences = utils.get_preferences() + self.diff_method = DIFF_BINARY + + def resolve(self): + # TODO: generic check + # Ensure local cache directory + os.makedirs(self.preferences.cache_directory, exist_ok=True) + + if self.data: + self.instance = Path(get_filepath(self.data['name'])) + + def _dump(self, instance=None): + """ + Read the file and return a dict as: + { + name : filename + extension : + file: file content + } + """ + logging.info(f"Extracting file metadata") + + data = { + 'name': self.instance.name, + } + + logging.info( + f"Reading {self.instance.name} content: {self.instance.stat().st_size} bytes") + + try: + file = open(self.instance, "rb") + data['file'] = file.read() + + file.close() + except IOError: + logging.warning(f"{self.instance} doesn't exist, skipping") + else: + file.close() + + return data + + def _load(self, data, target): + """ + Writing the file + """ + logging.info(f"Writing {data['name']} to {target}") + + # TODO: check fiile already exist + # TODO: check for empty data + if target.exists() and (sys.getsizeof(data['file']) == self.instance.stat().st_size): + logging.info("File already loaded, skipping.") + return + try: + file = open(target, "wb") + file.write(data['file']) + except IOError: + logging.warning(f"{target} doesn't exist, skipping") + else: + file.close() + + def diff(self): + return False diff --git a/multi_user/bl_types/bl_font.py b/multi_user/bl_types/bl_font.py index 599f3ad..3e45d92 100644 --- a/multi_user/bl_types/bl_font.py +++ b/multi_user/bl_types/bl_font.py @@ -16,14 +16,18 @@ # ##### END GPL LICENSE BLOCK ##### +import logging +import os +from pathlib import Path + import bpy import mathutils -import os -import logging -import pathlib + from .. import utils -from .dump_anything import Loader, Dumper from .bl_datablock import BlDatablock +from .bl_file import get_filepath +from .dump_anything import Dumper, Loader + class BlFont(BlDatablock): bl_id = "fonts" @@ -37,33 +41,28 @@ class BlFont(BlDatablock): def _construct(self, data): if data['filepath'] == '': return bpy.data.fonts.load(data['filepath']) - elif 'font_file' in data.keys(): - prefs = utils.get_preferences() - ext = pathlib.Path(data['filepath']).suffix - font_name = f"{self.uuid}{ext}" - font_path = os.path.join(prefs.cache_directory, font_name) - - os.makedirs(prefs.cache_directory, exist_ok=True) - file = open(font_path, 'wb') - file.write(data["font_file"]) - file.close() + else: + filename = Path(data['filepath']).name - logging.info(f'loading {font_path}') - return bpy.data.fonts.load(font_path) + logging.info(f'loading {filename}') + + return bpy.data.fonts.load(get_filepath(filename)) def _load(self, data, target): pass def _dump(self, instance=None): - data = { - 'filepath':instance.filepath, - 'name':instance.name + return { + 'filepath': instance.filepath, + 'name': instance.name } - if instance.filepath != '' and not instance.is_embedded_data: - file = open(instance.filepath, "rb") - data['font_file'] = file.read() - file.close() - return data def diff(self): return False + + def _resolve_deps_implementation(self): + deps = [] + if self.instance.filepath and self.instance.filepath != '': + deps.append(Path(self.instance.filepath)) + + return deps From b77ab2dd05c8875403b1b88558a93c5a1ba629ff Mon Sep 17 00:00:00 2001 From: Swann Date: Sun, 20 Sep 2020 23:31:24 +0200 Subject: [PATCH 2/9] feat: use bl_file to replicate font and sound files --- multi_user/bl_types/bl_file.py | 13 ++++---- multi_user/bl_types/bl_font.py | 8 ++--- multi_user/bl_types/bl_sound.py | 59 +++++++++++++-------------------- 3 files changed, 32 insertions(+), 48 deletions(-) diff --git a/multi_user/bl_types/bl_file.py b/multi_user/bl_types/bl_file.py index 36c2348..406247e 100644 --- a/multi_user/bl_types/bl_file.py +++ b/multi_user/bl_types/bl_file.py @@ -44,7 +44,7 @@ class BlFile(ReplicatedDatablock): bl_id = 'file' bl_name = "file" bl_class = Path - bl_delay_refresh = 1 + bl_delay_refresh = 0 bl_delay_apply = 1 bl_automatic_push = True bl_check_common = False @@ -56,7 +56,6 @@ class BlFile(ReplicatedDatablock): # TODO: handle packed_file # TODO: ensure absolute path # TODO: ensure file exist - logging.info(self.instance) self.preferences = utils.get_preferences() self.diff_method = DIFF_BINARY @@ -105,18 +104,20 @@ class BlFile(ReplicatedDatablock): """ logging.info(f"Writing {data['name']} to {target}") - # TODO: check fiile already exist # TODO: check for empty data - if target.exists() and (sys.getsizeof(data['file']) == self.instance.stat().st_size): + + if target.exists() and not self.diff(): logging.info("File already loaded, skipping.") return try: file = open(target, "wb") - file.write(data['file']) + file.write() except IOError: logging.warning(f"{target} doesn't exist, skipping") else: file.close() def diff(self): - return False + memory_size = sys.getsizeof(self.data['file'])-33 + disk_size = self.instance.stat().st_size + return memory_size == disk_size diff --git a/multi_user/bl_types/bl_font.py b/multi_user/bl_types/bl_font.py index 3e45d92..7e81dce 100644 --- a/multi_user/bl_types/bl_font.py +++ b/multi_user/bl_types/bl_font.py @@ -21,9 +21,7 @@ import os from pathlib import Path import bpy -import mathutils -from .. import utils from .bl_datablock import BlDatablock from .bl_file import get_filepath from .dump_anything import Dumper, Loader @@ -43,13 +41,11 @@ class BlFont(BlDatablock): return bpy.data.fonts.load(data['filepath']) else: filename = Path(data['filepath']).name - - logging.info(f'loading {filename}') - return bpy.data.fonts.load(get_filepath(filename)) def _load(self, data, target): - pass + loader = Loader() + loader.load(target, data) def _dump(self, instance=None): return { diff --git a/multi_user/bl_types/bl_sound.py b/multi_user/bl_types/bl_sound.py index bd9bafb..91b163b 100644 --- a/multi_user/bl_types/bl_sound.py +++ b/multi_user/bl_types/bl_sound.py @@ -16,14 +16,16 @@ # ##### END GPL LICENSE BLOCK ##### -import bpy -import mathutils -import os import logging -import pathlib -from .. import utils -from .dump_anything import Loader, Dumper +import os +from pathlib import Path + +import bpy + +from .bl_file import get_filepath from .bl_datablock import BlDatablock +from .dump_anything import Dumper, Loader + class BlSound(BlDatablock): bl_id = "sounds" @@ -35,40 +37,25 @@ class BlSound(BlDatablock): bl_icon = 'SOUND' def _construct(self, data): - if 'file' in data.keys(): - prefs = utils.get_preferences() - ext = data['filepath'].split(".")[-1] - sound_name = f"{self.uuid}.{ext}" - sound_path = os.path.join(prefs.cache_directory, sound_name) - - os.makedirs(prefs.cache_directory, exist_ok=True) - file = open(sound_path, 'wb') - file.write(data["file"]) - file.close() - - logging.info(f'loading {sound_path}') - return bpy.data.sounds.load(sound_path) + filename = Path(data['filepath']).name + return bpy.data.sounds.load(get_filepath(filename)) def _load(self, data, target): loader = Loader() loader.load(target, data) - def _dump(self, instance=None): - if not instance.packed_file: - # prefs = utils.get_preferences() - # ext = pathlib.Path(instance.filepath).suffix - # sound_name = f"{self.uuid}{ext}" - # sound_path = os.path.join(prefs.cache_directory, sound_name) - # instance.filepath = sound_path - instance.pack() - #TODO:use file locally with unpack(method='USE_ORIGINAL') ? - - return { - 'filepath':instance.filepath, - 'name':instance.name, - 'file': instance.packed_file.data - } - - def diff(self): return False + + def _dump(self, instance=None): + return { + 'filepath': instance.filepath, + 'name': instance.name + } + + def _resolve_deps_implementation(self): + deps = [] + if self.instance.filepath and self.instance.filepath != '': + deps.append(Path(self.instance.filepath)) + + return deps From af3afc1124fa4f81121966d848f75886d598050e Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 21 Sep 2020 00:11:37 +0200 Subject: [PATCH 3/9] feat: use bl_file in bl_image --- multi_user/bl_types/bl_image.py | 75 +++++++++++++-------------------- 1 file changed, 29 insertions(+), 46 deletions(-) diff --git a/multi_user/bl_types/bl_image.py b/multi_user/bl_types/bl_image.py index 669d9a9..0f5b1fd 100644 --- a/multi_user/bl_types/bl_image.py +++ b/multi_user/bl_types/bl_image.py @@ -16,13 +16,17 @@ # ##### END GPL LICENSE BLOCK ##### +import logging +import os +from pathlib import Path + import bpy import mathutils -import os -import logging + from .. import utils -from .dump_anything import Loader, Dumper from .bl_datablock import BlDatablock +from .dump_anything import Dumper, Loader +from .bl_file import get_filepath format_to_ext = { 'BMP': 'bmp', @@ -53,29 +57,6 @@ class BlImage(BlDatablock): bl_check_common = False bl_icon = 'IMAGE_DATA' - def dump_image(self, image): - pixels = None - if image.source == "GENERATED" or image.packed_file is not None: - prefs = utils.get_preferences() - img_name = f"{self.uuid}.{format_to_ext[image.file_format]}" - - # Cache the image on the disk - image.filepath_raw = os.path.join(prefs.cache_directory, img_name) - os.makedirs(prefs.cache_directory, exist_ok=True) - image.save() - - if image.source == "FILE": - image_path = bpy.path.abspath(image.filepath_raw) - image_directory = os.path.dirname(image_path) - os.makedirs(image_directory, exist_ok=True) - image.save() - file = open(image_path, "rb") - pixels = file.read() - file.close() - else: - raise ValueError() - return pixels - def _construct(self, data): return bpy.data.images.new( name=data['name'], @@ -84,28 +65,27 @@ class BlImage(BlDatablock): ) def _load(self, data, target): - image = target - prefs = utils.get_preferences() - img_format = data['file_format'] - img_name = f"{self.uuid}.{format_to_ext[img_format]}" - - img_path = os.path.join(prefs.cache_directory, img_name) - os.makedirs(prefs.cache_directory, exist_ok=True) - file = open(img_path, 'wb') - file.write(data["pixels"]) - file.close() - - image.source = 'FILE' - image.filepath = img_path - image.colorspace_settings.name = data["colorspace_settings"]["name"] + target.source = 'FILE' + target.filepath_raw = get_filepath(data['filename']) + target.colorspace_settings.name = data["colorspace_settings"]["name"] loader = Loader() loader.load(data, target) def _dump(self, instance=None): assert(instance) - data = {} - data['pixels'] = self.dump_image(instance) + + filename = Path(instance.filepath).name + + # Cache the image on the disk + if instance.source == "GENERATED" or instance.packed_file is not None: + instance.filepath = get_filepath(filename) + instance.save() + + data = { + "filename": filename + } + dumper = Dumper() dumper.depth = 2 dumper.include_filter = [ @@ -114,13 +94,9 @@ class BlImage(BlDatablock): 'height', 'alpha', 'float_buffer', - 'file_format', 'alpha_mode', - 'filepath', - 'source', 'colorspace_settings'] data.update(dumper.dump(instance)) - return data def diff(self): @@ -128,3 +104,10 @@ class BlImage(BlDatablock): return True else: return False + + def _resolve_deps_implementation(self): + deps = [] + if self.instance.filepath: + deps.append(Path(self.instance.filepath)) + + return deps From f992d06b031a85b1492488259a8020c614c2fb23 Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 21 Sep 2020 12:12:19 +0200 Subject: [PATCH 4/9] feat: handle packed datablock feat: filecache settings --- multi_user/bl_types/bl_file.py | 31 +++++++++++++++++++++++-------- multi_user/bl_types/bl_font.py | 4 +++- multi_user/bl_types/bl_image.py | 28 +++++++++++++++++++--------- multi_user/bl_types/bl_sound.py | 4 +++- multi_user/preferences.py | 12 ++++++++++++ multi_user/ui.py | 16 ++++++++++++++++ 6 files changed, 76 insertions(+), 19 deletions(-) diff --git a/multi_user/bl_types/bl_file.py b/multi_user/bl_types/bl_file.py index 406247e..2682077 100644 --- a/multi_user/bl_types/bl_file.py +++ b/multi_user/bl_types/bl_file.py @@ -34,10 +34,20 @@ def get_filepath(filename): """ Construct the local filepath """ - return os.path.join( + return str(Path( utils.get_preferences().cache_directory, filename - ) + )) + + +def ensure_unpacked(datablock): + if datablock.packed_file: + logging.info(f"Unpacking {datablock.name}") + + filename = Path(bpy.path.abspath(datablock.filepath)).name + datablock.filepath = get_filepath(filename) + + datablock.unpack(method="WRITE_ORIGINAL") class BlFile(ReplicatedDatablock): @@ -53,7 +63,6 @@ class BlFile(ReplicatedDatablock): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.instance = kwargs.get('instance', None) - # TODO: handle packed_file # TODO: ensure absolute path # TODO: ensure file exist @@ -62,12 +71,17 @@ class BlFile(ReplicatedDatablock): def resolve(self): # TODO: generic check - # Ensure local cache directory os.makedirs(self.preferences.cache_directory, exist_ok=True) if self.data: self.instance = Path(get_filepath(self.data['name'])) + def push(self, socket, identity=None): + super().push(socket, identity=None) + + if self.preferences.clear_memory_filecache: + del self.data['file'] + def _dump(self, instance=None): """ Read the file and return a dict as: @@ -102,16 +116,17 @@ class BlFile(ReplicatedDatablock): """ Writing the file """ - logging.info(f"Writing {data['name']} to {target}") - # TODO: check for empty data if target.exists() and not self.diff(): - logging.info("File already loaded, skipping.") + logging.info(f"{data['name']} already on the disk, skipping.") return try: file = open(target, "wb") - file.write() + file.write(data['file']) + + if self.preferences.clear_memory_filecache: + del self.data['file'] except IOError: logging.warning(f"{target} doesn't exist, skipping") else: diff --git a/multi_user/bl_types/bl_font.py b/multi_user/bl_types/bl_font.py index 7e81dce..157f9f2 100644 --- a/multi_user/bl_types/bl_font.py +++ b/multi_user/bl_types/bl_font.py @@ -23,7 +23,7 @@ from pathlib import Path import bpy from .bl_datablock import BlDatablock -from .bl_file import get_filepath +from .bl_file import get_filepath, ensure_unpacked from .dump_anything import Dumper, Loader @@ -59,6 +59,8 @@ class BlFont(BlDatablock): def _resolve_deps_implementation(self): deps = [] if self.instance.filepath and self.instance.filepath != '': + ensure_unpacked(self.instance) + deps.append(Path(self.instance.filepath)) return deps diff --git a/multi_user/bl_types/bl_image.py b/multi_user/bl_types/bl_image.py index 0f5b1fd..6026ca2 100644 --- a/multi_user/bl_types/bl_image.py +++ b/multi_user/bl_types/bl_image.py @@ -26,7 +26,7 @@ import mathutils from .. import utils from .bl_datablock import BlDatablock from .dump_anything import Dumper, Loader -from .bl_file import get_filepath +from .bl_file import get_filepath, ensure_unpacked format_to_ext = { 'BMP': 'bmp', @@ -65,23 +65,19 @@ class BlImage(BlDatablock): ) def _load(self, data, target): + loader = Loader() + loader.load(data, target) + target.source = 'FILE' target.filepath_raw = get_filepath(data['filename']) target.colorspace_settings.name = data["colorspace_settings"]["name"] - - loader = Loader() - loader.load(data, target) + def _dump(self, instance=None): assert(instance) filename = Path(instance.filepath).name - # Cache the image on the disk - if instance.source == "GENERATED" or instance.packed_file is not None: - instance.filepath = get_filepath(filename) - instance.save() - data = { "filename": filename } @@ -108,6 +104,20 @@ class BlImage(BlDatablock): def _resolve_deps_implementation(self): deps = [] if self.instance.filepath: + + if self.instance.packed_file: + filename = Path(bpy.path.abspath(self.instance.filepath)).name + self.instance.filepath = get_filepath(filename) + self.instance.save() + # An image can't be unpacked to the modified path + # TODO: make a bug report + self.instance.unpack(method="REMOVE") + + elif self.instance.source == "GENERATED": + filename = f"{self.instance.name}.png" + self.instance.filepath = get_filepath(filename) + self.instance.save() + deps.append(Path(self.instance.filepath)) return deps diff --git a/multi_user/bl_types/bl_sound.py b/multi_user/bl_types/bl_sound.py index 91b163b..93f3990 100644 --- a/multi_user/bl_types/bl_sound.py +++ b/multi_user/bl_types/bl_sound.py @@ -22,7 +22,7 @@ from pathlib import Path import bpy -from .bl_file import get_filepath +from .bl_file import get_filepath, ensure_unpacked from .bl_datablock import BlDatablock from .dump_anything import Dumper, Loader @@ -56,6 +56,8 @@ class BlSound(BlDatablock): def _resolve_deps_implementation(self): deps = [] if self.instance.filepath and self.instance.filepath != '': + ensure_unpacked(self.instance) + deps.append(Path(self.instance.filepath)) return deps diff --git a/multi_user/preferences.py b/multi_user/preferences.py index 90ec490..0af44c9 100644 --- a/multi_user/preferences.py +++ b/multi_user/preferences.py @@ -162,6 +162,11 @@ class SessionPrefs(bpy.types.AddonPreferences): description="Enable objects update in edit mode (! Impact performances !)", default=False ) + clear_memory_filecache: bpy.props.BoolProperty( + name="Clear memory filecache", + description="Remove filecache from memory", + default=False + ) # for UI category: bpy.props.EnumProperty( name="Category", @@ -230,6 +235,12 @@ class SessionPrefs(bpy.types.AddonPreferences): description="sidebar_advanced_net_expanded", default=False ) + sidebar_advanced_cache_expanded: bpy.props.BoolProperty( + name="sidebar_advanced_cache_expanded", + description="sidebar_advanced_cache_expanded", + default=False + ) + auto_check_update: bpy.props.BoolProperty( name="Auto-check for Update", description="If enabled, auto-check for updates using an interval", @@ -343,6 +354,7 @@ class SessionPrefs(bpy.types.AddonPreferences): emboss=False) if self.conf_session_cache_expanded: box.row().prop(self, "cache_directory", text="Cache directory") + box.row().prop(self, "clear_memory_filecache", text="Clear memory filecache") # INTERFACE SETTINGS box = grid.box() diff --git a/multi_user/ui.py b/multi_user/ui.py index ef79e9c..429cd57 100644 --- a/multi_user/ui.py +++ b/multi_user/ui.py @@ -356,6 +356,22 @@ class SESSION_PT_advanced_settings(bpy.types.Panel): replication_timers.label(text="Update rate (ms):") replication_timers.prop(settings, "depsgraph_update_rate", text="") + cache_section = layout.row().box() + cache_section.prop( + settings, + "sidebar_advanced_cache_expanded", + text="Cache", + icon=get_expanded_icon(settings.sidebar_advanced_cache_expanded), + emboss=False) + if settings.sidebar_advanced_cache_expanded: + cache_section_row = cache_section.row() + cache_section_row.label(text="Cache directory:") + cache_section_row = cache_section.row() + cache_section_row.prop(settings, "cache_directory", text="") + cache_section_row = cache_section.row() + cache_section_row.label(text="Clear memory filecache:") + cache_section_row.prop(settings, "clear_memory_filecache", text="") + log_section = layout.row().box() log_section.prop( settings, From 6c47e095be44bf2f7e2f2aa09aa6c3b00e85123b Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 21 Sep 2020 16:47:49 +0200 Subject: [PATCH 5/9] feat: cache managenent utility --- multi_user/bl_types/bl_file.py | 6 ---- multi_user/operators.py | 32 ++++++++++++++++- multi_user/preferences.py | 16 ++++++++- multi_user/ui.py | 5 +-- multi_user/utils.py | 66 ++++++++++++++++++++++++++++++++-- 5 files changed, 113 insertions(+), 12 deletions(-) diff --git a/multi_user/bl_types/bl_file.py b/multi_user/bl_types/bl_file.py index 2682077..6b07c43 100644 --- a/multi_user/bl_types/bl_file.py +++ b/multi_user/bl_types/bl_file.py @@ -63,16 +63,10 @@ class BlFile(ReplicatedDatablock): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.instance = kwargs.get('instance', None) - # TODO: ensure absolute path - # TODO: ensure file exist - self.preferences = utils.get_preferences() self.diff_method = DIFF_BINARY def resolve(self): - # TODO: generic check - os.makedirs(self.preferences.cache_directory, exist_ok=True) - if self.data: self.instance = Path(get_filepath(self.data['name'])) diff --git a/multi_user/operators.py b/multi_user/operators.py index 381d824..7353823 100644 --- a/multi_user/operators.py +++ b/multi_user/operators.py @@ -27,6 +27,8 @@ from operator import itemgetter from pathlib import Path from subprocess import PIPE, Popen, TimeoutExpired import zmq +import shutil +from pathlib import Path import bpy import mathutils @@ -608,6 +610,34 @@ class ApplyArmatureOperator(bpy.types.Operator): stop_modal_executor = False +class ClearCache(bpy.types.Operator): + "Clear local session cache" + bl_idname = "session.clear_cache" + bl_label = "Modal Executor Operator" + + @classmethod + def poll(cls, context): + return True + + def execute(self, context): + cache_dir = utils.get_preferences().cache_directory + try: + for root, dirs, files in os.walk(cache_dir): + for name in files: + Path(root, name).unlink() + + except Exception as e: + self.report({'ERROR'}, repr(e)) + + return {"FINISHED"} + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + row = self.layout + row.label(text=f" Do you really want to remove local cache ? ") + classes = ( SessionStartOperator, SessionStopOperator, @@ -620,7 +650,7 @@ classes = ( ApplyArmatureOperator, SessionKickOperator, SessionInitOperator, - + ClearCache, ) diff --git a/multi_user/preferences.py b/multi_user/preferences.py index 0af44c9..905833f 100644 --- a/multi_user/preferences.py +++ b/multi_user/preferences.py @@ -20,6 +20,9 @@ import logging import bpy import string import re +import os + +from pathlib import Path from . import bl_types, environment, addon_updater_ops, presence, ui from .utils import get_preferences, get_expanded_icon @@ -68,6 +71,16 @@ def update_port(self, context): self['ipc_port'] = random.randrange(self.port+4, 10000) +def update_directory(self, context): + new_dir = Path(self.cache_directory) + if new_dir.exists() and any(Path(self.cache_directory).iterdir()): + logging.error("The folder is not empty, choose another one.") + self['cache_directory'] = environment.DEFAULT_CACHE_DIR + elif not new_dir.exists(): + logging.info("Target cache folder doesn't exist, creating it.") + os.makedirs(self.cache_directory, exist_ok=True) + + def set_log_level(self, value): logging.getLogger().setLevel(value) @@ -136,7 +149,8 @@ class SessionPrefs(bpy.types.AddonPreferences): cache_directory: bpy.props.StringProperty( name="cache directory", subtype="DIR_PATH", - default=environment.DEFAULT_CACHE_DIR) + default=environment.DEFAULT_CACHE_DIR, + update=update_directory) connection_timeout: bpy.props.IntProperty( name='connection timeout', description='connection timeout before disconnection', diff --git a/multi_user/ui.py b/multi_user/ui.py index 429cd57..954a1f0 100644 --- a/multi_user/ui.py +++ b/multi_user/ui.py @@ -19,7 +19,7 @@ import bpy from . import operators -from .utils import get_preferences, get_expanded_icon +from .utils import get_preferences, get_expanded_icon, get_folder_size from replication.constants import (ADDED, ERROR, FETCHED, MODIFIED, RP_COMMON, UP, STATE_ACTIVE, STATE_AUTH, @@ -371,7 +371,8 @@ class SESSION_PT_advanced_settings(bpy.types.Panel): cache_section_row = cache_section.row() cache_section_row.label(text="Clear memory filecache:") cache_section_row.prop(settings, "clear_memory_filecache", text="") - + cache_section_row = cache_section.row() + cache_section_row.operator('session.clear_cache', text=f"Clear cache ({get_folder_size(settings.cache_directory)})") log_section = layout.row().box() log_section.prop( settings, diff --git a/multi_user/utils.py b/multi_user/utils.py index f66ab90..177cd4b 100644 --- a/multi_user/utils.py +++ b/multi_user/utils.py @@ -21,8 +21,10 @@ import logging import os import sys import time -from uuid import uuid4 from collections.abc import Iterable +from pathlib import Path +from uuid import uuid4 +import math import bpy import mathutils @@ -78,6 +80,7 @@ def resolve_from_id(id, optionnal_type=None): return root[id] return None + def get_datablock_from_uuid(uuid, default, ignore=[]): if not uuid: return default @@ -87,9 +90,10 @@ def get_datablock_from_uuid(uuid, default, ignore=[]): if isinstance(root, Iterable) and category not in ignore: for item in root: if getattr(item, 'uuid', None) == uuid: - return item + return item return default + def get_preferences(): return bpy.context.preferences.addons[__package__].preferences @@ -103,3 +107,61 @@ def get_expanded_icon(prop: bpy.types.BoolProperty) -> str: return 'DISCLOSURE_TRI_DOWN' else: return 'DISCLOSURE_TRI_RIGHT' + + +# Taken from here: https://stackoverflow.com/a/55659577 +def get_folder_size(folder): + return ByteSize(sum(file.stat().st_size for file in Path(folder).rglob('*'))) + + +class ByteSize(int): + + _kB = 1024 + _suffixes = 'B', 'kB', 'MB', 'GB', 'PB' + + def __new__(cls, *args, **kwargs): + return super().__new__(cls, *args, **kwargs) + + def __init__(self, *args, **kwargs): + self.bytes = self.B = int(self) + self.kilobytes = self.kB = self / self._kB**1 + self.megabytes = self.MB = self / self._kB**2 + self.gigabytes = self.GB = self / self._kB**3 + self.petabytes = self.PB = self / self._kB**4 + *suffixes, last = self._suffixes + suffix = next(( + suffix + for suffix in suffixes + if 1 < getattr(self, suffix) < self._kB + ), last) + self.readable = suffix, getattr(self, suffix) + + super().__init__() + + def __str__(self): + return self.__format__('.2f') + + def __repr__(self): + return '{}({})'.format(self.__class__.__name__, super().__repr__()) + + def __format__(self, format_spec): + suffix, val = self.readable + return '{val:{fmt}} {suf}'.format(val=math.ceil(val), fmt=format_spec, suf=suffix) + + def __sub__(self, other): + return self.__class__(super().__sub__(other)) + + def __add__(self, other): + return self.__class__(super().__add__(other)) + + def __mul__(self, other): + return self.__class__(super().__mul__(other)) + + def __rsub__(self, other): + return self.__class__(super().__sub__(other)) + + def __radd__(self, other): + return self.__class__(super().__add__(other)) + + def __rmul__(self, other): + return self.__class__(super().__rmul__(other)) From c533d4b86a58bda5dd58687c0bb6cf3c9723775e Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 21 Sep 2020 17:31:07 +0200 Subject: [PATCH 6/9] ci: run tests on every branch --- .gitlab/ci/test.gitlab-ci.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 8ee1972..ccacbb3 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -3,8 +3,3 @@ test: image: slumber/blender-addon-testing:latest script: - python3 scripts/test_addon.py - - only: - refs: - - master - - develop From fb622fa098b921c5bb6f8a75a8c2dd77f8edb6c4 Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 21 Sep 2020 17:37:06 +0200 Subject: [PATCH 7/9] fix: get_datablock_users attribute error --- multi_user/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi_user/utils.py b/multi_user/utils.py index 177cd4b..75c1391 100644 --- a/multi_user/utils.py +++ b/multi_user/utils.py @@ -49,7 +49,7 @@ def get_datablock_users(datablock): if hasattr(datablock, 'users_group') and datablock.users_scene: users.extend(list(datablock.users_scene)) for datatype in supported_types: - if datatype.bl_name != 'users': + if datatype.bl_name != 'users' and hasattr(bpy.data, datatype.bl_name): root = getattr(bpy.data, datatype.bl_name) for item in root: if hasattr(item, 'data') and datablock == item.data or \ From 9adc0d7d6e1e44d52e2f1444c117270a20030f61 Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 21 Sep 2020 17:48:07 +0200 Subject: [PATCH 8/9] clean: remove image testing (until the file replication interface is done) --- tests/test_bl_types/test_image.py | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 tests/test_bl_types/test_image.py diff --git a/tests/test_bl_types/test_image.py b/tests/test_bl_types/test_image.py deleted file mode 100644 index e597526..0000000 --- a/tests/test_bl_types/test_image.py +++ /dev/null @@ -1,21 +0,0 @@ -import os - -import pytest -from deepdiff import DeepDiff - -import bpy -import random -from multi_user.bl_types.bl_image import BlImage - -def test_image(clear_blend): - datablock = bpy.data.images.new('asd',2000,2000) - - implementation = BlImage() - expected = implementation._dump(datablock) - bpy.data.images.remove(datablock) - - test = implementation._construct(expected) - implementation._load(expected, test) - result = implementation._dump(test) - - assert not DeepDiff(expected, result) From 167b39f15eca2d06ed22d83c86ed22ef7a4bfc5f Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 21 Sep 2020 18:14:30 +0200 Subject: [PATCH 9/9] doc: added a cache section to the quickstart --- .../img/quickstart_advanced_cache.png | Bin 0 -> 7757 bytes docs/getting_started/quickstart.rst | 20 ++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 docs/getting_started/img/quickstart_advanced_cache.png diff --git a/docs/getting_started/img/quickstart_advanced_cache.png b/docs/getting_started/img/quickstart_advanced_cache.png new file mode 100644 index 0000000000000000000000000000000000000000..fd56043d41279515d24d319014543066110d541d GIT binary patch literal 7757 zcmZ8`cT^Km_bp9|8X{mp5CREO1Pm%gX%Q&_k*ZRaNbe%OCnzXQ1q4BA=pZe01SC;F z2p}T8_W)8tFw~F$FMjX6-}~14W9F_|bMKv*Gk2e}_rB4F`f$K$zS9g03;-Q%4P$zX zqaVratn_o7j`;z-VR2N`Q)6JLiaSHIK1ILgc%g0X&A`Cf`tM-8AbvlLfkA+zqoMXF zz=}9cNfgnfux^n~=0X=Z1Ovjwa>HVTu7d;68@W>{;zHnS;Dy&V#Sv`T#R0h$;FzZD z*nrs>sXwPJWp>P?neN`gV49eD-~ofCi5qFQp+ffa#Z*4$ zSWh#jfVRTeq!uV(G6c)iyxHX1FsQF2_u#TWjyhes3TR_a@odUqh{by(EB{dgmMA(q znPFrYUns2{g30N^b*%uvy1c8k%}j0rE~mkX;PFSA zkHQ}mt92|fpSAzhF=&()A*P(uJbSd6QsL^S`#t;*hggPTudA7$t=LJ+rY}abcwCRK zW~ZYS1W@ppnL66VpoqwwJdkh0l$Y-QM@&e;qLUY`oJvsJgY?ujhZCJ?)HX)|8e#KgOBP{FPR#mCveSZ62I#rsKrsg?0(&gRn z!>(2_DQ{Q<*ZpQg_XU{PCWGmroVFF*{yn@cg+rsll~SDwI+N@P5wncw5<4ZRHX(T3x6$2rKVY%W1An zu%lIjX;%bwLSrYTWy5}YcNBr%Ky9bbicsYl{o2R6P(N>U<}-4TLH<*o>m`GuUO(ok zQIPk4kVoqXe}C+Drhjn{WqQ-E;e5f;_thfwOz2iPXhZOC6g*#gz!pRuJ5SW(vwx1F zP8MNRcYdosOdDRHe~!1so#U-YSUv>SJsYy!E6sj^SBWA$2?i|0K1!ZRdkPY+1|bjT zqiMff<)J7_Pje3x_mw)ck4*vNya`xxw6Zfd+z(9PN=hkz(yC}Rtx<@^B-efz@iMV`}-KwQEBjQDPlT#;w<|*rL(>htI{`U zK;5V@*rzX?=fbVHxl_ph!=n58A~{XnQtRTt;%>9aG%l@Xj}W;Fo%cV1TAuu)heLV1POtlpqIRb(yqX zsC=viVxx?cF;~cw%VQ^`s!qW(*aJiVnF_3}9)4DaS2PTH^wUvw@MsB&T16hTfeKAC zqc#^~K(skF=blAzVvJ;kc4=6bwd8iWN2qHgo0Z4ltD&f0MUUHEszRxIb{I}cHOtrj z8jzFoLfnvA@lE03h`V;dLDaoS)t#JZyjwoRpP)jU2a-0u(bW9ffP+&=O7uE@|F^5L z&SH&QPyV^mSt;vfJS;Y|_Tme)nhQMsO|~aMG{)yeR#7KctH+a-y+DhZH`qMQ{{kcL znUTfhlK&r>|GYwStsPIung(up1f&T}y{{v$*bFE`o|8M+kCHUu{hU-6AcjzD2 zvnZ5Zs>RRVdVA}3`Tku6c`~&)4a_(!52|Rc7?(bksWXw6tZ=%R_uyuC zHBUJ5LfUpJUC0j^sYDM{x|_Nj!$R9eU+AXy^d;=pYZk>M?c6C{#NogueLW-7ZqFWc zC=A%CJtDwJU$>i}$iJ~Vxr_OU`u8J(`eUtzLaCFp`X4~xJ5(WyxeJ&8%@2PLXe%&u zQn%BXp&W$ewVUd~GD7f%IC%ydGJmEN*|Vpz-`Gc-*(p@Gn#$$QJlUKVr>Z#KXt1R!$^!L2~@&CES5#nozAACCh=os}}6(M!*mD-vGdIy%%(4mx4_3G!<-)j~<) zy<+3AppOmB(?o8Hrl%@J?4&c!F=+t$jz6Z;o;nqqhxag65ExRT8eLm+6@8H8YL}&KqK9ny zP_ME#5twy#1|@LgHIBn6i~r zq--xtuCd*&iVKy>)UCOCSp#`I?9QEx+@A?15lTl?v6Q#Cdk8J2Dh%cYfF*$1qpV4T{mD4#njTOG{Tqek%79&YjT%%Fq2;No%)0Thqe1`*}$ zMaG;ZPre^JfgSe8S#AA*$N#F6X6Ls&cl>93a_=k{ce7rp0Fl_?S3IIhsp|D4t=X;M zMOo}Uf+r_8Ux2d9vji%d)=GBPP3!L`7q2Z!Ln^x1yIAtS&==`n}RvMXS zbYm%YMD(MqY97FCTqDV0{W23*8~nYv0^XxM)F?98u;UKiJ8s?*nE_W3Em2U)l59cY4z!1LFm$%LhP~SvQ2iw!OzYScI{5+SY!yzRAl^;b(2T@AF%Yt-z6^^t%O5i(dZxZ#+ym zYP0I}FqvO8PV1OMxc+wC*Eg%#|D8+=W1OF#qL2O$-N{j*>4W}_8Tc!InGkCl|w@UJY*GFd;w?W=EN&@ZZz_!^amr`mH3ch z(Qn#>OMm(Q@{e#Yf?=N+Ou{~NLJ|;Zv(=+~)4ydn2?R0OIo+nP^^B>uFdSc;cLgSj z1^~Fe&Y&qwy-**}J$)eMTfp-ym*BW^Milgt`l-z~%LAB3iPe>0EHRn%2Z+f2p0<<;mk*u1Kmx3QJ8CG!e2;*KB1*(7kK1YqZ@>8o?$StJXf5hBTT7ye6 zT;KOBOwoYP88*7M`@5RD*$mYo*$`BGoe0rO zWQ21KAPSs3@Zkz{*^?=LZ?riXv#w5|366_n-p-x3AEO2!5O)yWRybasP~K2}8lKyh|`0xk4!6~_m5Tf#vsvVuV1~=4>P=V`)Frya8Lmx8houz*4Dts#$6n z^7rKI@gAYZ0fQLI>C2Bk_W4*_;Ksu_cfU60>&jAmA}(Z3fYr5$*ninR+*h>sfx`3b z?hxnf@GBCRc-|TJ36U*Z0a=WNr&mDwmHz4)1;6gBWSlw|VS~A{z?UrqPr9I&x2eu~ zN5oyCnz8G^#=)qJmBh~`g(i`%lL8J_n zFRg1Ic?a+3aQglwsp{z`mX3(-mjdc-8qwC3n|O@6kC~JIwYa-?d&eWP^>hZ&EjuLS ze)H#ymX$6s^w(#ojjGN<&f)&Qzq=d0*H!$JQzp z>fVWO*WJQ*Lv23XHD#sPv}>-f8snTsWz75uY#J5L{)Y{kir`ua zh?n*yiKrhzmE6;3$LO+WP~^rR{Id@Mq4+n4sXI>`AF7x>@zmtY9`h*8lcN3;ht`!U zw1IBuwlpb+)D?-cu{IWpY2r#pOk|O{zIngx(v3~>XrjuhOtYnLDHQj0D{3IQe3Pi0 zxCvo)6-t+Z)mWN%UYy>O2tQy>!F}!9wU2azN9_e@qqUFn^sH^)E~5R;#`@|YU$iNn z8&^56;|+)^+8*QGdB?9%coG2C!zyY5R#+9IO04=v+COxvb`uoSS;f1_u;Mahu5@JK z-Vz0mQO4Usp%YgTD&#x?&&^`}mHvy9DN_hQ_!{pLw#{(|(_mnPbIcCF59`BA(cVjx zXhkn-@hwa^YQ@l%2i^asrcYHV6F!;T)^rv2rC&&qCAy$9A#(>GE}l3EPt}f8Rv8G> zJFqAzYbsd6A4SBGt;n`xEV&h5XDa}Xm#!R>lcSxj>Nf6Zq}JN)O~U|VbyY0 z*GFPP^};@WTQ`E9?$AfVocGQ>cJ*m{#v8&VQ-7M0Y@J5P&?FEL(ljHv<5!usBIMu~ ziO*4{%W_fx=GZ@1^G7$RF`fx0&IZ92FBAi@$Vgq-s1EC3FM-rZbG*4a&HDu$M&40|qjVIa<54LM#AnVqtg zh0-pYg-i9-#pUQk@@_==iADnpLVMPfLux#wRYbI00)@F2(hNTUi`(|I6-|w^%6*V7 zOQA!HPlCR&tx)Q!&vT1Q90cj&g!AICk*g56{J^PnZBvhOlFQ!OJtXYXd)3~f(X~ub zhb$UF^PX)-HlN{^vlV{lN4BAN&+ZkybE^eb`HWk=MK8f48W!PGIvQnoNPy;Ac*k4P zGmh(xqNz5R+r|3fFgMCSZQ*RdK510crJog|a92wW zUE7J}02@k;!f6xNmL^gMDCN5XFu7n~Xa9=2zyText0&=y(M_+Rxw#~<=u}Z0XMdNl z!6*Ot?F?EjLmS~QhZBq8PAsv9b$U(6T*PAWyV|fRX0j9JaOk8C6DrMNnQ%4Q z37v==v6{HkOsuzbVY4YUIHHjedjhZJmt6Kb)SIUxW%rAw!w+>XAF7K0x2rPG(~-gY zaSuR_-LprYEyL%w0rEpV!tP(Lx@IIc8Y?moA;fxG;%rot4FmYqWg4$kzmoRdYX%w@ zt6Y48WI%<=t7j4nEy^4eW&&(o)N29J`45$p7NQ`=f`3ItQZ_A`>kva?k3X=zUOL2Y z58mj!k_Zl{WzL0lW4$Y;<)}h9YH}g5R$h~00ZaASIB+IFv(Lx8=!)XaCA9?F)zRdW~h7QifZrEowRxEifCG=}( zFzj}#BMDtS@Km}PqpYc;k<@v8;wLMm3!sG~5F8(_LypuRm;~nv&})snH??~9Ete`&s3w2HHfBdrV2ode+NTXk6-K-YWO z`sWk@RCOl@vgS} zIRZ?2JD8PIJmnkB#;&(ERN9k|3eS=)DK19@!F!f+xRify0AwA$J5+neH!D(^aOsq{ z3fx2W%Et>g)S(~Hy3iI`E-4@39z}pnR%FIr*_}WYhL|_@nG<56V`Rl7uNqh2D{xiU z!+Jf~72hEv=jOR`op-byN!v-DiOkKbhwQ7}d*B9aYK#GI1rc5p-6e*8*; zVBnf}7SQEMitqgu)YT;}b87AbbHVu0^n?cNZh040B-Rz1)So4e4u4wSGQnf7xf+1i z)OWU@{fw(kNgm~Qk6E{9Ifw10rm~EjqNuHC!0)i0Jst6GTiGB@?QF9)v}ZYL2Y&`f zBghZ*C^839DnW%INoOkSq;s;bShD-gi@^{0BBn&w!qtfJg}sgL^i;ga-e36TI`Jh* z+yCv+4m#mA69fR%yd!|lgyWB6IeF@xZzF5FScgF@Y3TazEjf>9JzUq z`>K*>WfWNwtqmoc&+}0_BaGOxt%LqZQ}~VeE&j{N{ePMHpSe5AK-3#Jy!+2&{*TCc z`v8fXd(uU^&c|2Ch`YJRv&|O?2g4S;H%xsfxKKBzPjYkO6$@@%iwSt>0`;?VdG(t& z=HVRAPKgI9b5nZrEv^CpHK#MA1E=Vk>C8_#iiGsa0%1M;r(?B*V@+?Vg+RirXB&q=&t_2f(aXdY2=Z0$-pk~hNMu*b~m-#3(kqgnfy`M5DFK2Ym`K5K$w(SidI$$&L;sxMh3gx)f%g6Lnn<{v%$G-l@w0N4%mj#c^-G_inXNYp`CT>kJ;^L- z%69kh7r@*$#%{L`cBE~CTQ9ZfRf9r=FCtjKF1yC7Tt4snB;@Z89TNP!3f%>xS7w~Q z@8`t`9)0b7DLq1A%_3i5!dMIB>iEY|y3OB}h1xfL-tbn=rc_tfv@-!Nn3^=FojZ1q zyW7ULZfVjI=3ew92kDz=BN1xA?+r(Whl_yY#ObomNA+c~5Uv=G*AcXYkA{dlevCh^ zZ)lPJoE7~d%Gpp;Au(ED!ENfx4X&_9LQ0A-)n^I^1<-b_q%;)($ahRsJWo%Qq1nQ6 zn0RHVhVI4sy#3g#k|-%_t|z;z)$B|alGPJ*=U|+;qK4cPy@Ha}?NeMY(?Ir#sUdkX z>f_3jrHj5T9@l9e-RSC8EPUMvfiaDbf>A^g=RboQ3}Xs7r_SE(b;&Bg+W-5hOZhF5 z;BD*A7w6d(gxNpdY}dFy$?Yo~-qo7LaDhABEVo=OMa*RxEAKIrQU6RXIPsxCLPf=T zMe}uca~Joz%+vQi>ze1simyj))fyYZXYQzF z^x;sEpAbw~(pMmOqiXeXTM75e_Leu_+&58apOnau_?M#lrhc_5UhxRR#Z~sTi8s75 z3z(U=;szysf+k73d+l=xhi7b98*}xsW1k+WX~B`Yh+LvXQARahuoj95_i*vw=&4tP zw~F_TgvQo48jP;ZZ_~G;^!v^XB;2gmSj(MSR_<1dpRIYb!o4!lmqJA7BTExG>{~4^ zmoZh5qiLNWR7lgWBLg1#4~YiV7HjD)JOVa|Gx8hBGh>4^c~7@!O5`Z!fz9!%g+C-5ev^L z`2P;kdO+s8^HFb}36eL`ou61NYr+%GF>+1c)NlTf*`T3q7t1uDaogxD#d$A<#R!DJ z+q>G3))_DUUHXWvRVPRh-81Q7Qn!qs@f|nuusrCSoKJmqTl$0f_oTYvYQUqEPz8j^ z?a;-XQNPacFVeb=k7Y-!5-g+-WM6V2PMT5z=Irz0hF6^W;x_Ok_c&tmw?xdR!H%f} zSlHY8%dFjXjPTn8ucYU>ybpjGtf%^u5*)Ih)FeW|!Mq;@RlOsip^H=5>Ce~=w|~^v zvQYvBCYCIyJ@xljU>?k`(zHcidD}UVrdk^PcfY)sPQk_mWk$jbJ;Fe9{JH2UX537E zqXPEsG|l{!GO)7mAL?|GGT*>S`U-Qu7(H#`0S+3)l}7LN^2fERBzls?L+_-RY!|#w zQ%Uk|-_5RHc7G<;G>NY9Cm!bkTMgo|KNGX45B-M_%c%?ldAXFZWMSL5h4MLOu#XT7jD=4|g5uKh|@7L2BSGpD|-vL1PvC%4dsCjcW9 zJ=NQ!ay!Pqm2}PiuC%I>q8xSnI0-4a@Z5>_Vvpv$j_v4Ef4!++jo2vKF|OkBfau+} zuM8=kd=1_eP%!L(6ygxg^lzKvDIzHKHsw6JDk&Okq=MSD X1h~MZYK8RuU