kapsikkum-unmanic – Blame information for rev 1
?pathlinks?
Rev | Author | Line No. | Line |
---|---|---|---|
1 | office | 1 | #!/usr/bin/env python3 |
2 | # -*- coding: utf-8 -*- |
||
3 | |||
4 | """ |
||
5 | unmanic.plugins.py |
||
6 | |||
7 | Written by: Josh.5 <jsunnex@gmail.com> |
||
8 | Date: 03 Mar 2021, (3:52 PM) |
||
9 | |||
10 | Copyright: |
||
11 | Copyright (C) Josh Sunnex - All Rights Reserved |
||
12 | |||
13 | Permission is hereby granted, free of charge, to any person obtaining a copy |
||
14 | of this software and associated documentation files (the "Software"), to deal |
||
15 | in the Software without restriction, including without limitation the rights |
||
16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||
17 | copies of the Software, and to permit persons to whom the Software is |
||
18 | furnished to do so, subject to the following conditions: |
||
19 | |||
20 | The above copyright notice and this permission notice shall be included in all |
||
21 | copies or substantial portions of the Software. |
||
22 | |||
23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
||
24 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
||
25 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. |
||
26 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, |
||
27 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR |
||
28 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE |
||
29 | OR OTHER DEALINGS IN THE SOFTWARE. |
||
30 | |||
31 | """ |
||
32 | import base64 |
||
33 | import hashlib |
||
34 | import json |
||
35 | import os |
||
36 | import shutil |
||
37 | import zipfile |
||
38 | from operator import attrgetter |
||
39 | |||
40 | import requests |
||
41 | |||
42 | from unmanic import config |
||
43 | from unmanic.libs import common, unlogger |
||
44 | from unmanic.libs.library import Library |
||
45 | from unmanic.libs.session import Session |
||
46 | from unmanic.libs.singleton import SingletonType |
||
47 | from unmanic.libs.unmodels import EnabledPlugins, LibraryPluginFlow, Plugins, PluginRepos |
||
48 | from unmanic.libs.unplugins import PluginExecutor |
||
49 | |||
50 | |||
51 | class PluginsHandler(object, metaclass=SingletonType): |
||
52 | """ |
||
53 | Set plugin version. |
||
54 | Plugins must be compatible with this version to be installed. |
||
55 | """ |
||
56 | version: int = 2 |
||
57 | |||
58 | def __init__(self, *args, **kwargs): |
||
59 | self.settings = config.Config() |
||
60 | unmanic_logging = unlogger.UnmanicLogger.__call__() |
||
61 | self.logger = unmanic_logging.get_logger(__class__.__name__) |
||
62 | |||
63 | def _log(self, message, message2='', level="info"): |
||
64 | message = common.format_message(message, message2) |
||
65 | getattr(self.logger, level)(message) |
||
66 | |||
67 | @staticmethod |
||
68 | def get_plugin_repo_id(repo_path): |
||
69 | return int(hashlib.md5(repo_path.encode('utf8')).hexdigest(), 16) |
||
70 | |||
71 | def get_repo_cache_file(self, repo_id): |
||
72 | plugins_directory = self.settings.get_plugins_path() |
||
73 | if not os.path.exists(plugins_directory): |
||
74 | os.makedirs(plugins_directory) |
||
75 | return os.path.join(plugins_directory, "repo-{}.json".format(repo_id)) |
||
76 | |||
77 | def get_plugin_path(self, plugin_id): |
||
78 | plugin_directory = os.path.join(self.settings.get_plugins_path(), plugin_id) |
||
79 | if not os.path.exists(plugin_directory): |
||
80 | os.makedirs(plugin_directory) |
||
81 | return plugin_directory |
||
82 | |||
83 | def get_plugin_download_cache_path(self, plugin_id, plugin_version): |
||
84 | plugin_directory = self.settings.get_plugins_path() |
||
85 | return os.path.join(plugin_directory, "{}-{}.zip".format(plugin_id, plugin_version)) |
||
86 | |||
87 | @staticmethod |
||
88 | def get_default_repo(): |
||
89 | return "default" |
||
90 | |||
91 | def get_plugin_repos(self): |
||
92 | """ |
||
93 | Returns a list of plugin repos |
||
94 | |||
95 | :return: |
||
96 | """ |
||
97 | default_repo = self.get_default_repo() |
||
98 | repo_list = [ |
||
99 | { |
||
100 | "path": default_repo |
||
101 | } |
||
102 | ] |
||
103 | |||
104 | repos = PluginRepos.select().order_by(PluginRepos.id.asc()) |
||
105 | for repo in repos: |
||
106 | repo_dict = repo.model_to_dict() |
||
107 | if repo_dict.get('path') == default_repo: |
||
108 | continue |
||
109 | repo_list.append(repo_dict) |
||
110 | |||
111 | return repo_list |
||
112 | |||
113 | def set_plugin_repos(self, repo_list): |
||
114 | # Ensure list of repo URLs is valid |
||
115 | for repo_path in repo_list: |
||
116 | repo_data = self.fetch_remote_repo_data(repo_path) |
||
117 | if not repo_data: |
||
118 | return False |
||
119 | |||
120 | # Remove all existing repos |
||
121 | PluginRepos.delete().execute() |
||
122 | |||
123 | # Add new repos |
||
124 | data = [] |
||
125 | for repo_path in repo_list: |
||
126 | data.append({"path": repo_path}) |
||
127 | |||
128 | PluginRepos.insert_many(data).execute() |
||
129 | |||
130 | return True |
||
131 | |||
132 | def fetch_remote_repo_data(self, repo_path): |
||
133 | # Fetch remote JSON file |
||
134 | session = Session() |
||
135 | uuid = session.get_installation_uuid() |
||
136 | level = session.get_supporter_level() |
||
137 | repo = base64.b64encode(repo_path.encode('utf-8')).decode('utf-8') |
||
138 | api_path = f'plugin_repos/get_repo_data/uuid/{uuid}/level/{level}/repo/{repo}' |
||
139 | data, status_code = session.api_get( |
||
140 | 'unmanic-api', |
||
141 | 1, |
||
142 | api_path, |
||
143 | ) |
||
144 | if status_code >= 500: |
||
145 | self._log(f"Failed to fetch plugin repo from '{api_path}'. Code:{status_code}", level="debug") |
||
146 | return data |
||
147 | |||
148 | def update_plugin_repos(self): |
||
149 | """ |
||
150 | Updates the local cached data of plugin repos |
||
151 | |||
152 | :return: |
||
153 | """ |
||
154 | plugins_directory = self.settings.get_plugins_path() |
||
155 | if not os.path.exists(plugins_directory): |
||
156 | os.makedirs(plugins_directory) |
||
157 | current_repos_list = self.get_plugin_repos() |
||
158 | for repo in current_repos_list: |
||
159 | repo_path = repo.get('path') |
||
160 | repo_id = self.get_plugin_repo_id(repo_path) |
||
161 | |||
162 | # Fetch remote JSON file |
||
163 | repo_data = self.fetch_remote_repo_data(repo_path) |
||
164 | |||
165 | # Dumb object to local JSON file |
||
166 | repo_cache = self.get_repo_cache_file(repo_id) |
||
167 | self._log("Repo cache file '{}'.".format(repo_cache), level="info") |
||
168 | try: |
||
169 | with open(repo_cache, 'w') as f: |
||
170 | json.dump(repo_data, f, indent=4) |
||
171 | except json.JSONDecodeError as e: |
||
172 | self._log("Unable to update plugin repo '{}'.".format(repo_path), str(e), level="error") |
||
173 | return True |
||
174 | |||
175 | def get_settings_of_all_installed_plugins(self): |
||
176 | all_settings = {} |
||
177 | |||
178 | # First fetch all enabled plugins |
||
179 | order = [ |
||
180 | { |
||
181 | "column": 'name', |
||
182 | "dir": 'asc', |
||
183 | }, |
||
184 | ] |
||
185 | installed_plugins = self.get_plugin_list_filtered_and_sorted(order=order) |
||
186 | |||
187 | # Fetch settings for each plugin |
||
188 | plugin_executor = PluginExecutor() |
||
189 | for plugin in installed_plugins: |
||
190 | plugin_settings, plugin_settings_meta = plugin_executor.get_plugin_settings(plugin.get('plugin_id')) |
||
191 | all_settings[plugin.get('plugin_id')] = plugin_settings |
||
192 | |||
193 | # Return modules |
||
194 | return all_settings |
||
195 | |||
196 | def read_repo_data(self, repo_id): |
||
197 | repo_cache = self.get_repo_cache_file(repo_id) |
||
198 | if os.path.exists(repo_cache): |
||
199 | with open(repo_cache) as f: |
||
200 | repo_data = json.load(f) |
||
201 | return repo_data |
||
202 | return {} |
||
203 | |||
204 | def get_plugin_info(self, plugin_id): |
||
205 | plugin_info = {} |
||
206 | plugin_directory = os.path.join(self.settings.get_plugins_path(), plugin_id) |
||
207 | info_file = os.path.join(plugin_directory, 'info.json') |
||
208 | if os.path.exists(info_file): |
||
209 | # Read plugin info.json |
||
210 | with open(info_file) as json_file: |
||
211 | plugin_info = json.load(json_file) |
||
212 | return plugin_info |
||
213 | |||
214 | def get_plugins_in_repo_data(self, repo_data): |
||
215 | return_list = [] |
||
216 | if 'repo' in repo_data and 'plugins' in repo_data: |
||
217 | # Get URLs for plugin downloads |
||
218 | repo_meta = repo_data.get("repo") |
||
219 | repo_data_directory = repo_meta.get("repo_data_directory") |
||
220 | if not repo_data_directory: |
||
221 | return return_list |
||
222 | repo_data_directory = repo_data_directory.rstrip('/') |
||
223 | # if not repo_data_directory.endswith("/"): |
||
224 | # repo_data_directory = repo_data_directory + "/" |
||
225 | |||
226 | # Loop over |
||
227 | for plugin in repo_data.get("plugins", []): |
||
228 | # Only show plugins that are compatible with this version |
||
229 | # Plugins will require a 'compatibility' entry in their info.json file. |
||
230 | # This must list the plugin handler versions that it is compatible with |
||
231 | if self.version not in plugin.get('compatibility', []): |
||
232 | continue |
||
233 | |||
234 | plugin_package_url = "{0}/{1}/{1}-{2}.zip".format(repo_data_directory, plugin.get('id'), plugin.get('version')) |
||
235 | plugin_changelog_url = "{0}/{1}/changelog.md".format(repo_data_directory, plugin.get('id')) |
||
236 | |||
237 | # Check if plugin is already installed: |
||
238 | plugin_status = { |
||
239 | 'installed': False, |
||
240 | } |
||
241 | plugin_info = self.get_plugin_info(plugin.get('id')) |
||
242 | if plugin_info: |
||
243 | local_version = plugin_info.get('version') |
||
244 | # Parse the currently installed version number and check if it matches |
||
245 | remote_version = plugin.get('version') |
||
246 | if local_version == remote_version: |
||
247 | plugin_status = { |
||
248 | 'installed': True, |
||
249 | 'update_available': False, |
||
250 | } |
||
251 | else: |
||
252 | # There is an update available |
||
253 | self.flag_plugin_for_update_by_id(plugin.get("id")) |
||
254 | plugin_status = { |
||
255 | 'installed': True, |
||
256 | 'update_available': True, |
||
257 | } |
||
258 | |||
259 | return_list.append( |
||
260 | { |
||
261 | 'plugin_id': plugin.get('id'), |
||
262 | 'name': plugin.get('name'), |
||
263 | 'author': plugin.get('author'), |
||
264 | 'description': plugin.get('description'), |
||
265 | 'version': plugin.get('version'), |
||
266 | 'icon': plugin.get('icon', ''), |
||
267 | 'tags': plugin.get('tags'), |
||
268 | 'status': plugin_status, |
||
269 | 'package_url': plugin_package_url, |
||
270 | 'changelog_url': plugin_changelog_url, |
||
271 | 'repo_name': repo_meta.get('name'), |
||
272 | } |
||
273 | ) |
||
274 | return return_list |
||
275 | |||
276 | def get_installable_plugins_list(self, filter_repo_id=None): |
||
277 | """ |
||
278 | Return a list of plugins that can be installed |
||
279 | Optionally filter by repo |
||
280 | |||
281 | :param filter_repo_id: |
||
282 | :return: |
||
283 | """ |
||
284 | return_list = [] |
||
285 | |||
286 | # First fetch a list of available repos |
||
287 | current_repos_list = self.get_plugin_repos() |
||
288 | for repo in current_repos_list: |
||
289 | repo_path = repo.get('path') |
||
290 | repo_id = self.get_plugin_repo_id(repo_path) |
||
291 | if filter_repo_id and repo_id != int(filter_repo_id): |
||
292 | # Filtering by repo ID and this one does not match |
||
293 | continue |
||
294 | repo_data = self.read_repo_data(repo_id) |
||
295 | plugins_in_repo = self.get_plugins_in_repo_data(repo_data) |
||
296 | for plugin_data in plugins_in_repo: |
||
297 | plugin_data['repo_id'] = str(repo_id) |
||
298 | return_list += plugins_in_repo |
||
299 | |||
300 | return return_list |
||
301 | |||
302 | def read_remote_changelog_file(self, changelog_url): |
||
303 | r = requests.get(changelog_url, timeout=1) |
||
304 | if r.status_code == 200: |
||
305 | return r.text |
||
306 | return '' |
||
307 | |||
308 | def notify_site_of_plugin_install(self, plugin): |
||
309 | """ |
||
310 | Notify the unmanic.app site API of the installation. |
||
311 | This is used for metric stats so that we can get a count of plugin downloads. |
||
312 | |||
313 | :param plugin: |
||
314 | :return: |
||
315 | """ |
||
316 | # Post |
||
317 | session = Session() |
||
318 | uuid = session.get_installation_uuid() |
||
319 | level = session.get_supporter_level() |
||
320 | post_data = { |
||
321 | "uuid": uuid, |
||
322 | "level": level, |
||
323 | "plugin_id": plugin.get("plugin_id"), |
||
324 | "author": plugin.get("author"), |
||
325 | "version": plugin.get("version"), |
||
326 | } |
||
327 | try: |
||
328 | repo_data, status_code = session.api_post('unmanic-api', 1, 'plugin_repos/record_install', post_data) |
||
329 | if not repo_data.get('success'): |
||
330 | session.register_unmanic() |
||
331 | except Exception as e: |
||
332 | self._log("Exception while logging plugin install.", str(e), level="debug") |
||
333 | return False |
||
334 | |||
335 | def install_plugin_by_id(self, plugin_id, repo_id=None): |
||
336 | """ |
||
337 | Find the matching plugin info for the given plugin ID. |
||
338 | Download the plugin if it is found and return the result. |
||
339 | If it is not found, return False. |
||
340 | |||
341 | :param plugin_id: |
||
342 | :param repo_id: |
||
343 | :return: |
||
344 | """ |
||
345 | plugin_list = self.get_installable_plugins_list(filter_repo_id=repo_id) |
||
346 | for plugin in plugin_list: |
||
347 | if plugin.get('plugin_id') == plugin_id: |
||
348 | success = self.download_and_install_plugin(plugin) |
||
349 | |||
350 | if success: |
||
351 | try: |
||
352 | # Write the plugin info to the DB |
||
353 | plugin_directory = self.get_plugin_path(plugin.get("plugin_id")) |
||
354 | result = self.write_plugin_data_to_db(plugin, plugin_directory) |
||
355 | if result: |
||
356 | self._log("Installed plugin '{}'".format(plugin_id), level="info") |
||
357 | |||
358 | # Ensure the plugin module is reloaded (if it was previously loaded) |
||
359 | plugin_executor = PluginExecutor() |
||
360 | plugin_executor.reload_plugin_module(plugin.get('plugin_id')) |
||
361 | |||
362 | return result |
||
363 | except Exception as e: |
||
364 | self._log("Exception while installing plugin '{}'.".format(plugin), str(e), level="exception") |
||
365 | |||
366 | return False |
||
367 | |||
368 | def install_plugin_from_path_on_disk(self, abspath): |
||
369 | """ |
||
370 | Install a plugin from a ZIP file on disk |
||
371 | |||
372 | :param abspath: |
||
373 | :return: |
||
374 | """ |
||
375 | # TODO: Ensure that this is a zip file |
||
376 | try: |
||
377 | plugin_info = self.install_plugin(abspath) |
||
378 | |||
379 | # Set the plugin_id variable used when writing data to DB. |
||
380 | # The returned 'plugin_info' is just a readout of the info.json file which has this set to 'id' |
||
381 | plugin_info['plugin_id'] = plugin_info.get('id') |
||
382 | |||
383 | # Cleanup zip file |
||
384 | if os.path.isfile(abspath): |
||
385 | os.remove(abspath) |
||
386 | |||
387 | # Write the plugin info to the DB |
||
388 | plugin_directory = self.get_plugin_path(plugin_info.get("plugin_id")) |
||
389 | result = self.write_plugin_data_to_db(plugin_info, plugin_directory) |
||
390 | if result: |
||
391 | self._log("Installed plugin '{}'".format(plugin_info.get("plugin_id")), level="info") |
||
392 | |||
393 | # Ensure the plugin module is reloaded (if it was previously loaded) |
||
394 | plugin_executor = PluginExecutor() |
||
395 | plugin_executor.reload_plugin_module(plugin_info.get('plugin_id')) |
||
396 | |||
397 | return result |
||
398 | except Exception as e: |
||
399 | self._log("Exception while installing plugin from zip '{}'.".format(abspath), str(e), level="exception") |
||
400 | |||
401 | return False |
||
402 | |||
403 | def download_and_install_plugin(self, plugin): |
||
404 | """ |
||
405 | Download and install a given plugin |
||
406 | |||
407 | :param plugin: |
||
408 | :return: |
||
409 | """ |
||
410 | self._log("Installing plugin '{}'".format(plugin.get("name")), level='debug') |
||
411 | # Try to fetch URL |
||
412 | try: |
||
413 | # Fetch remote zip file |
||
414 | destination = self.download_plugin(plugin) |
||
415 | |||
416 | # Install downloaded plugin |
||
417 | self.install_plugin(destination, plugin.get("plugin_id")) |
||
418 | |||
419 | # Cleanup zip file |
||
420 | if os.path.isfile(destination): |
||
421 | os.remove(destination) |
||
422 | |||
423 | self.notify_site_of_plugin_install(plugin) |
||
424 | |||
425 | return True |
||
426 | |||
427 | except Exception as e: |
||
428 | success = False |
||
429 | self._log("Exception while installing plugin '{}'.".format(plugin), str(e), level="exception") |
||
430 | |||
431 | return False |
||
432 | |||
433 | def download_plugin(self, plugin): |
||
434 | """ |
||
435 | Download a given plugin to a temp directory |
||
436 | |||
437 | :param plugin: |
||
438 | :return: |
||
439 | """ |
||
440 | # Fetch remote zip file |
||
441 | destination = self.get_plugin_download_cache_path(plugin.get("plugin_id"), plugin.get("version")) |
||
442 | self._log("Downloading plugin '{}' to '{}'".format(plugin.get("package_url"), destination), level='debug') |
||
443 | with requests.get(plugin.get("package_url"), stream=True, allow_redirects=True) as r: |
||
444 | r.raise_for_status() |
||
445 | with open(destination, 'wb') as f: |
||
446 | for chunk in r.iter_content(chunk_size=128): |
||
447 | f.write(chunk) |
||
448 | return destination |
||
449 | |||
450 | def install_plugin(self, zip_file, plugin_id=None): |
||
451 | """ |
||
452 | Install a given plugin from a zip file |
||
453 | |||
454 | :param zip_file: |
||
455 | :param plugin_id: |
||
456 | :return: |
||
457 | """ |
||
458 | # Read plugin ID from zip contents info.json if no plugin_id was provided |
||
459 | if not plugin_id: |
||
460 | with zipfile.ZipFile(zip_file, "r") as zip_ref: |
||
461 | plugin_info = json.loads(zip_ref.read('info.json')) |
||
462 | plugin_id = plugin_info.get('id') |
||
463 | # Create plugin destination directory based on plugin ID |
||
464 | plugin_directory = self.get_plugin_path(plugin_id) |
||
465 | # Prevent installation if destination has a git repository. This plugin is probably under development |
||
466 | self._log(os.path.join(str(plugin_directory), '.git')) |
||
467 | if os.path.exists(os.path.join(str(plugin_directory), '.git')): |
||
468 | raise Exception("Plugin directory contains a git repository. Uninstall this source version before installing.") |
||
469 | # Extract zip file contents |
||
470 | self._log("Extracting plugin to '{}'".format(plugin_directory), level='debug') |
||
471 | with zipfile.ZipFile(zip_file, "r") as zip_ref: |
||
472 | zip_ref.extractall(str(plugin_directory)) |
||
473 | # Return installed plugin info |
||
474 | return self.get_plugin_info(plugin_id) |
||
475 | |||
476 | @staticmethod |
||
477 | def write_plugin_data_to_db(plugin, plugin_directory): |
||
478 | # Add installed plugin to database |
||
479 | plugin_data = { |
||
480 | Plugins.plugin_id: plugin.get("plugin_id"), |
||
481 | Plugins.name: plugin.get("name"), |
||
482 | Plugins.author: plugin.get("author"), |
||
483 | Plugins.version: plugin.get("version"), |
||
484 | Plugins.tags: plugin.get("tags"), |
||
485 | Plugins.description: plugin.get("description"), |
||
486 | Plugins.icon: plugin.get("icon"), |
||
487 | Plugins.local_path: plugin_directory, |
||
488 | Plugins.update_available: False, |
||
489 | } |
||
490 | plugin_entry = Plugins.get_or_none(plugin_id=plugin.get("plugin_id")) |
||
491 | if plugin_entry is not None: |
||
492 | # Update the existing entry |
||
493 | update_query = (Plugins |
||
494 | .update(plugin_data) |
||
495 | .where(Plugins.plugin_id == plugin.get("plugin_id"))) |
||
496 | update_query.execute() |
||
497 | else: |
||
498 | # Insert a new entry |
||
499 | Plugins.insert(plugin_data).execute() |
||
500 | |||
501 | return True |
||
502 | |||
503 | def get_total_plugin_list_count(self): |
||
504 | task_query = Plugins.select().order_by(Plugins.id.desc()) |
||
505 | return task_query.count() |
||
506 | |||
507 | def get_plugin_list_filtered_and_sorted(self, order=None, start=0, length=None, search_value=None, id_list=None, |
||
508 | enabled=None, plugin_id=None, plugin_type=None, library_id=None): |
||
509 | try: |
||
510 | query = (Plugins.select()) |
||
511 | |||
512 | if plugin_type: |
||
513 | if library_id is not None: |
||
514 | join_condition = ( |
||
515 | (LibraryPluginFlow.plugin_id == Plugins.id) & (LibraryPluginFlow.plugin_type == plugin_type) & ( |
||
516 | LibraryPluginFlow.library_id == library_id)) |
||
517 | else: |
||
518 | join_condition = ( |
||
519 | (LibraryPluginFlow.plugin_id == Plugins.id) & (LibraryPluginFlow.plugin_type == plugin_type)) |
||
520 | query = query.join(LibraryPluginFlow, join_type='LEFT OUTER JOIN', on=join_condition) |
||
521 | |||
522 | if id_list: |
||
523 | query = query.where(Plugins.id.in_(id_list)) |
||
524 | |||
525 | if search_value: |
||
526 | query = query.where((Plugins.name.contains(search_value)) | (Plugins.author.contains(search_value)) | ( |
||
527 | Plugins.tags.contains(search_value))) |
||
528 | |||
529 | if plugin_id is not None: |
||
530 | query = query.where(Plugins.plugin_id.in_([plugin_id])) |
||
531 | |||
532 | # Deprecate this "enabled" status as plugins are now enabled when the are assigned to a library |
||
533 | if enabled is not None: |
||
534 | raise Exception("Fetching plugins by 'enabled' status is deprecated") |
||
535 | |||
536 | if library_id is not None: |
||
537 | join_condition = ( |
||
538 | (EnabledPlugins.plugin_id == Plugins.id) & (EnabledPlugins.library_id == library_id)) |
||
539 | query = query.join(EnabledPlugins, join_type='LEFT OUTER JOIN', on=join_condition) |
||
540 | query = query.where(EnabledPlugins.plugin_id != None) |
||
541 | |||
542 | # Get order by |
||
543 | if order: |
||
544 | for o in order: |
||
545 | if o.get("model"): |
||
546 | model = o.get("model") |
||
547 | else: |
||
548 | model = Plugins |
||
549 | if o.get("dir") == "asc": |
||
550 | order_by = attrgetter(o.get("column"))(model).asc() |
||
551 | else: |
||
552 | order_by = attrgetter(o.get("column"))(model).desc() |
||
553 | |||
554 | query = query.order_by_extend(order_by) |
||
555 | |||
556 | if length: |
||
557 | query = query.limit(length).offset(start) |
||
558 | |||
559 | return query.dicts() |
||
560 | |||
561 | except Plugins.DoesNotExist: |
||
562 | # No plugin entries exist yet |
||
563 | self._log("No plugins exist yet.", level="warning") |
||
564 | |||
565 | def flag_plugin_for_update_by_id(self, plugin_id): |
||
566 | self._log("Flagging update available for installed plugin '{}'".format(plugin_id), level='debug') |
||
567 | # Disable the matching entries in the table |
||
568 | Plugins.update(update_available=True).where(Plugins.plugin_id == plugin_id).execute() |
||
569 | |||
570 | # Fetch records |
||
571 | records = self.get_plugin_list_filtered_and_sorted(plugin_id=plugin_id) |
||
572 | |||
573 | # Ensure they are now disabled |
||
574 | for record in records: |
||
575 | if record.get('update_available'): |
||
576 | continue |
||
577 | self._log("Failed to flag plugin for update '{}'".format(record.get('plugin_id')), level='debug') |
||
578 | return False |
||
579 | |||
580 | return True |
||
581 | |||
582 | def uninstall_plugins_by_db_table_id(self, plugin_table_ids: list): |
||
583 | """ |
||
584 | Remove a Plugin by it's DB table ID column. |
||
585 | This will also remove the Plugin directory and all it's contents. |
||
586 | |||
587 | :param plugin_table_ids: |
||
588 | :return: |
||
589 | """ |
||
590 | self._log("Uninstall plugins '{}'".format(plugin_table_ids), level='debug') |
||
591 | |||
592 | # Fetch records |
||
593 | records_by_id = self.get_plugin_list_filtered_and_sorted(id_list=plugin_table_ids) |
||
594 | |||
595 | # Remove each plugin from disk |
||
596 | for record in records_by_id: |
||
597 | # Unload plugin modules |
||
598 | try: |
||
599 | PluginExecutor.unload_plugin_module(record.get('plugin_id')) |
||
600 | except Exception as e: |
||
601 | self._log("Exception while unloading python module {}:".format(record.get('plugin_id')), message2=str(e), |
||
602 | level="exception") |
||
603 | |||
604 | # Remove from disk |
||
605 | plugin_directory = self.get_plugin_path(record.get('plugin_id')) |
||
606 | self._log("Removing plugin files from disk '{}'".format(plugin_directory), level='debug') |
||
607 | try: |
||
608 | # Delete the info file first to prevent any other process trying to read the plugin. |
||
609 | # Without the info file, the plugin is effectivly uninstalled |
||
610 | info_file = os.path.join(plugin_directory, 'info.json') |
||
611 | if os.path.exists(info_file): |
||
612 | os.remove(info_file) |
||
613 | # Cleanup the rest of the plugin directory |
||
614 | shutil.rmtree(plugin_directory) |
||
615 | except Exception as e: |
||
616 | self._log("Exception while removing directory {}:".format(plugin_directory), message2=str(e), |
||
617 | level="exception") |
||
618 | |||
619 | # Unlink from library by ID in DB |
||
620 | EnabledPlugins.delete().where(EnabledPlugins.plugin_id.in_(plugin_table_ids)).execute() |
||
621 | |||
622 | # Delete by ID in DB |
||
623 | if not Plugins.delete().where(Plugins.id.in_(plugin_table_ids)).execute(): |
||
624 | return False |
||
625 | |||
626 | return True |
||
627 | |||
628 | def update_plugins_by_db_table_id(self, plugin_table_ids): |
||
629 | self._log("Update plugins '{}'".format(plugin_table_ids), level='debug') |
||
630 | |||
631 | # Fetch records |
||
632 | records_by_id = self.get_plugin_list_filtered_and_sorted(id_list=plugin_table_ids) |
||
633 | |||
634 | # Update each plugin in turn |
||
635 | for record in records_by_id: |
||
636 | if self.install_plugin_by_id(record.get('plugin_id')): |
||
637 | continue |
||
638 | self._log("Failed to update plugin '{}'".format(record.get('plugin_id')), level='debug') |
||
639 | return False |
||
640 | |||
641 | return True |
||
642 | |||
643 | def set_plugin_flow(self, plugin_type, library_id, flow): |
||
644 | """ |
||
645 | Update the plugin flow for all plugins in a given plugin type |
||
646 | |||
647 | :param plugin_type: |
||
648 | :param library_id: |
||
649 | :param flow: |
||
650 | :return: |
||
651 | """ |
||
652 | # Delete all current flow data for this plugin type |
||
653 | delete_query = LibraryPluginFlow.delete().where( |
||
654 | (LibraryPluginFlow.plugin_type == plugin_type) & (LibraryPluginFlow.library_id == library_id)) |
||
655 | delete_query.execute() |
||
656 | |||
657 | success = True |
||
658 | priority = 1 |
||
659 | for plugin in flow: |
||
660 | plugin_id = plugin.get('plugin_id') |
||
661 | |||
662 | # Fetch the plugin info |
||
663 | plugin_info = Plugins.select().where(Plugins.plugin_id == plugin_id).first() |
||
664 | if not plugin_info: |
||
665 | continue |
||
666 | |||
667 | # Save the plugin flow |
||
668 | plugin_flow = self.set_plugin_flow_position_for_single_plugin(plugin_info, plugin_type, library_id, priority) |
||
669 | priority += 1 |
||
670 | |||
671 | if not plugin_flow: |
||
672 | success = False |
||
673 | |||
674 | return success |
||
675 | |||
676 | @staticmethod |
||
677 | def set_plugin_flow_position_for_single_plugin(plugin_info: Plugins, plugin_type: str, library_id: int, priority: int): |
||
678 | """ |
||
679 | Update the plugin flow for a single plugin and type with the provided priority. |
||
680 | |||
681 | :param plugin_info: |
||
682 | :param plugin_type: |
||
683 | :param library_id: |
||
684 | :param priority: |
||
685 | :return: |
||
686 | """ |
||
687 | pass |
||
688 | # Save the plugin flow |
||
689 | flow_dict = { |
||
690 | 'plugin_id': plugin_info.id, |
||
691 | 'library_id': library_id, |
||
692 | 'plugin_name': plugin_info.plugin_id, |
||
693 | 'plugin_type': plugin_type, |
||
694 | 'position': priority, |
||
695 | } |
||
696 | plugin_flow = LibraryPluginFlow.create(**flow_dict) |
||
697 | |||
698 | return plugin_flow |
||
699 | |||
700 | def get_enabled_plugin_modules_by_type(self, plugin_type, library_id=None): |
||
701 | """ |
||
702 | Return a list of enabled plugin modules when given a plugin type |
||
703 | |||
704 | Runners are filtered by the given 'plugin_type' and sorted by |
||
705 | configured order of execution. |
||
706 | |||
707 | If no library ID is provided, this will return all installed plugins for that type. |
||
708 | This case should only be used for plugin runner types that are not associated with a library. |
||
709 | |||
710 | :param plugin_type: |
||
711 | :param library_id: |
||
712 | :return: |
||
713 | """ |
||
714 | # Refresh session |
||
715 | s = Session() |
||
716 | s.register_unmanic() |
||
717 | |||
718 | # First fetch all enabled plugins |
||
719 | order = [ |
||
720 | { |
||
721 | "model": LibraryPluginFlow, |
||
722 | "column": 'position', |
||
723 | "dir": 'asc', |
||
724 | }, |
||
725 | { |
||
726 | "column": 'name', |
||
727 | "dir": 'asc', |
||
728 | }, |
||
729 | ] |
||
730 | enabled_plugins = self.get_plugin_list_filtered_and_sorted(order=order, plugin_type=plugin_type, library_id=library_id) |
||
731 | |||
732 | # Fetch all plugin modules from the given list of enabled plugins |
||
733 | plugin_executor = PluginExecutor() |
||
734 | plugin_data = plugin_executor.get_plugin_data_by_type(enabled_plugins, plugin_type) |
||
735 | |||
736 | # Return modules |
||
737 | return plugin_data |
||
738 | |||
739 | def exec_plugin_runner(self, data, plugin_id, plugin_type): |
||
740 | """ |
||
741 | Execute a plugin runner |
||
742 | |||
743 | :param data: |
||
744 | :param plugin_id: |
||
745 | :param plugin_type: |
||
746 | :return: |
||
747 | """ |
||
748 | plugin_executor = PluginExecutor() |
||
749 | return plugin_executor.execute_plugin_runner(data, plugin_id, plugin_type) |
||
750 | |||
751 | def get_incompatible_enabled_plugins(self, frontend_messages=None): |
||
752 | """ |
||
753 | Ensure that the currently installed plugins are compatible with this PluginsHandler version |
||
754 | |||
755 | :param frontend_messages: |
||
756 | :return: |
||
757 | :rtype: |
||
758 | """ |
||
759 | # Fetch all libraries |
||
760 | all_libraries = Library.get_all_libraries() |
||
761 | |||
762 | def add_frontend_message(plugin_id, name): |
||
763 | # If the frontend messages queue was included in request, append a message |
||
764 | if frontend_messages: |
||
765 | frontend_messages.put( |
||
766 | { |
||
767 | 'id': 'incompatiblePlugin_{}'.format(plugin_id), |
||
768 | 'type': 'error', |
||
769 | 'code': 'incompatiblePlugin', |
||
770 | 'message': name, |
||
771 | 'timeout': 0 |
||
772 | } |
||
773 | ) |
||
774 | |||
775 | # Fetch all enabled plugins |
||
776 | incompatible_list = [] |
||
777 | for library in all_libraries: |
||
778 | enabled_plugins = self.get_plugin_list_filtered_and_sorted(library_id=library.get('id')) |
||
779 | |||
780 | # Ensure only compatible plugins are enabled |
||
781 | # If all enabled plugins are compatible, then return true |
||
782 | for record in enabled_plugins: |
||
783 | try: |
||
784 | # Ensure plugin is compatible |
||
785 | plugin_info = self.get_plugin_info(record.get('plugin_id')) |
||
786 | except Exception as e: |
||
787 | plugin_info = None |
||
788 | self._log("Exception while fetching plugin info for {}:".format(record.get('plugin_id')), message2=str(e), |
||
789 | level="exception") |
||
790 | if plugin_info: |
||
791 | # Plugins will require a 'compatibility' entry in their info.json file. |
||
792 | # This must list the plugin handler versions that it is compatible with |
||
793 | if self.version in plugin_info.get('compatibility', []): |
||
794 | continue |
||
795 | |||
796 | incompatible_list.append( |
||
797 | { |
||
798 | 'plugin_id': record.get('plugin_id'), |
||
799 | 'name': record.get('name'), |
||
800 | } |
||
801 | ) |
||
802 | add_frontend_message(record.get('plugin_id'), record.get('name')) |
||
803 | |||
804 | return incompatible_list |
||
805 | |||
806 | @staticmethod |
||
807 | def get_plugin_types_with_flows(): |
||
808 | """ |
||
809 | Returns a list of all available plugin types |
||
810 | |||
811 | :return: |
||
812 | """ |
||
813 | return_plugin_types = [] |
||
814 | plugin_ex = PluginExecutor() |
||
815 | types_list = plugin_ex.get_all_plugin_types() |
||
816 | # Filter out the types without flows |
||
817 | for plugin_type in types_list: |
||
818 | if plugin_type.get('has_flow'): |
||
819 | return_plugin_types.append(plugin_type.get('id')) |
||
820 | return return_plugin_types |
||
821 | |||
822 | def get_enabled_plugin_flows_for_plugin_type(self, plugin_type, library_id): |
||
823 | """ |
||
824 | Fetch all enabled plugin flows for a plugin type |
||
825 | |||
826 | :param plugin_type: |
||
827 | :param library_id: |
||
828 | :return: |
||
829 | """ |
||
830 | return_plugin_flow = [] |
||
831 | for plugin_module in self.get_enabled_plugin_modules_by_type(plugin_type, library_id=library_id): |
||
832 | return_plugin_flow.append( |
||
833 | { |
||
834 | "plugin_id": plugin_module.get("plugin_id"), |
||
835 | "name": plugin_module.get("name", ""), |
||
836 | "author": plugin_module.get("author", ""), |
||
837 | "description": plugin_module.get("description", ""), |
||
838 | "version": plugin_module.get("version", ""), |
||
839 | "icon": plugin_module.get("icon", ""), |
||
840 | } |
||
841 | ) |
||
842 | return return_plugin_flow |