351 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			Python
		
	
	
	
			
		
		
	
	
			351 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			Python
		
	
	
	
import copy
 | 
						|
import logging
 | 
						|
import os
 | 
						|
import subprocess
 | 
						|
import sys
 | 
						|
 | 
						|
import persisting_theory
 | 
						|
from django.core.cache import cache
 | 
						|
from django.db.models import Q
 | 
						|
from rest_framework import serializers
 | 
						|
 | 
						|
logger = logging.getLogger("plugins")
 | 
						|
 | 
						|
 | 
						|
class Startup(persisting_theory.Registry):
 | 
						|
    look_into = "persisting_theory"
 | 
						|
 | 
						|
 | 
						|
class Ready(persisting_theory.Registry):
 | 
						|
    look_into = "persisting_theory"
 | 
						|
 | 
						|
 | 
						|
startup = Startup()
 | 
						|
ready = Ready()
 | 
						|
 | 
						|
_plugins = {}
 | 
						|
_filters = {}
 | 
						|
_hooks = {}
 | 
						|
 | 
						|
 | 
						|
class PluginCache:
 | 
						|
    def __init__(self, prefix):
 | 
						|
        self.prefix = prefix
 | 
						|
 | 
						|
    def get(self, key, default=None):
 | 
						|
        key = ":".join([self.prefix, key])
 | 
						|
        return cache.get(key, default)
 | 
						|
 | 
						|
    def set(self, key, value, duration=None):
 | 
						|
        key = ":".join([self.prefix, key])
 | 
						|
        return cache.set(key, value, duration)
 | 
						|
 | 
						|
 | 
						|
def get_plugin_config(
 | 
						|
    name,
 | 
						|
    user=False,
 | 
						|
    source=False,
 | 
						|
    registry=_plugins,
 | 
						|
    conf={},
 | 
						|
    settings={},
 | 
						|
    description=None,
 | 
						|
    version=None,
 | 
						|
    label=None,
 | 
						|
    homepage=None,
 | 
						|
):
 | 
						|
    conf = {
 | 
						|
        "name": name,
 | 
						|
        "label": label or name,
 | 
						|
        "logger": logger,
 | 
						|
        # conf is for dynamic settings
 | 
						|
        "conf": conf,
 | 
						|
        # settings is for settings hardcoded in .env
 | 
						|
        "settings": settings,
 | 
						|
        "user": True if source else user,
 | 
						|
        # source plugins are plugins that provide audio content
 | 
						|
        "source": source,
 | 
						|
        "description": description,
 | 
						|
        "version": version,
 | 
						|
        "cache": PluginCache(name),
 | 
						|
        "homepage": homepage,
 | 
						|
    }
 | 
						|
    registry[name] = conf
 | 
						|
    return conf
 | 
						|
 | 
						|
 | 
						|
def load_settings(name, settings):
 | 
						|
    from django.conf import settings as django_settings
 | 
						|
 | 
						|
    mapping = {
 | 
						|
        "boolean": django_settings.ENV.bool,
 | 
						|
        "text": django_settings.ENV,
 | 
						|
    }
 | 
						|
    values = {}
 | 
						|
    prefix = f"FUNKWHALE_PLUGIN_{name.upper()}"
 | 
						|
    for s in settings:
 | 
						|
        key = "_".join([prefix, s["name"].upper()])
 | 
						|
        value = mapping[s["type"]](key, default=s.get("default", None))
 | 
						|
        values[s["name"]] = value
 | 
						|
 | 
						|
    logger.debug("Plugin %s running with settings %s", name, values)
 | 
						|
    return values
 | 
						|
 | 
						|
 | 
						|
def get_session():
 | 
						|
    from funkwhale_api.common import session
 | 
						|
 | 
						|
    return session.get_session()
 | 
						|
 | 
						|
 | 
						|
def register_filter(name, plugin_config, registry=_filters):
 | 
						|
    def decorator(func):
 | 
						|
        handlers = registry.setdefault(name, [])
 | 
						|
 | 
						|
        def inner(*args, **kwargs):
 | 
						|
            plugin_config["logger"].debug("Calling filter for %s", name)
 | 
						|
            rval = func(*args, **kwargs)
 | 
						|
            return rval
 | 
						|
 | 
						|
        handlers.append((plugin_config["name"], inner))
 | 
						|
        return inner
 | 
						|
 | 
						|
    return decorator
 | 
						|
 | 
						|
 | 
						|
def register_hook(name, plugin_config, registry=_hooks):
 | 
						|
    def decorator(func):
 | 
						|
        handlers = registry.setdefault(name, [])
 | 
						|
 | 
						|
        def inner(*args, **kwargs):
 | 
						|
            plugin_config["logger"].debug("Calling hook for %s", name)
 | 
						|
            func(*args, **kwargs)
 | 
						|
 | 
						|
        handlers.append((plugin_config["name"], inner))
 | 
						|
        return inner
 | 
						|
 | 
						|
    return decorator
 | 
						|
 | 
						|
 | 
						|
class Skip(Exception):
 | 
						|
    pass
 | 
						|
 | 
						|
 | 
						|
def trigger_filter(name, value, enabled=False, **kwargs):
 | 
						|
    """
 | 
						|
    Call filters registered for "name" with the given
 | 
						|
    args and kwargs.
 | 
						|
 | 
						|
    Return the value (that could be modified by handlers)
 | 
						|
    """
 | 
						|
    logger.debug("Calling handlers for filter %s", name)
 | 
						|
    registry = kwargs.pop("registry", _filters)
 | 
						|
    confs = kwargs.pop("confs", {})
 | 
						|
    for plugin_name, handler in registry.get(name, []):
 | 
						|
        if not enabled and confs.get(plugin_name, {}).get("enabled") is False:
 | 
						|
            continue
 | 
						|
        try:
 | 
						|
            value = handler(value, conf=confs.get(plugin_name, {}), **kwargs)
 | 
						|
        except Skip:
 | 
						|
            pass
 | 
						|
        except Exception as e:
 | 
						|
            logger.warn("Plugin %s errored during filter %s: %s", plugin_name, name, e)
 | 
						|
    return value
 | 
						|
 | 
						|
 | 
						|
def trigger_hook(name, enabled=False, **kwargs):
 | 
						|
    """
 | 
						|
    Call hooks registered for "name" with the given
 | 
						|
    args and kwargs.
 | 
						|
 | 
						|
    Returns nothing
 | 
						|
    """
 | 
						|
    logger.debug("Calling handlers for hook %s", name)
 | 
						|
    registry = kwargs.pop("registry", _hooks)
 | 
						|
    confs = kwargs.pop("confs", {})
 | 
						|
    for plugin_name, handler in registry.get(name, []):
 | 
						|
        if not enabled and confs.get(plugin_name, {}).get("enabled") is False:
 | 
						|
            continue
 | 
						|
        try:
 | 
						|
            handler(conf=confs.get(plugin_name, {}).get("conf"), **kwargs)
 | 
						|
        except Skip:
 | 
						|
            pass
 | 
						|
        except Exception as e:
 | 
						|
            logger.warn("Plugin %s errored during hook %s: %s", plugin_name, name, e)
 | 
						|
 | 
						|
 | 
						|
def set_conf(name, conf, user=None, registry=_plugins):
 | 
						|
    from funkwhale_api.common import models
 | 
						|
 | 
						|
    if not registry[name]["conf"] and not registry[name]["source"]:
 | 
						|
        return
 | 
						|
    conf_serializer = get_serializer_from_conf_template(
 | 
						|
        registry[name]["conf"],
 | 
						|
        user=user,
 | 
						|
        source=registry[name]["source"],
 | 
						|
    )(data=conf)
 | 
						|
    conf_serializer.is_valid(raise_exception=True)
 | 
						|
    if "library" in conf_serializer.validated_data:
 | 
						|
        conf_serializer.validated_data["library"] = str(
 | 
						|
            conf_serializer.validated_data["library"]
 | 
						|
        )
 | 
						|
    conf, _ = models.PluginConfiguration.objects.update_or_create(
 | 
						|
        user=user, code=name, defaults={"conf": conf_serializer.validated_data}
 | 
						|
    )
 | 
						|
 | 
						|
 | 
						|
def get_confs(user=None):
 | 
						|
    from funkwhale_api.common import models
 | 
						|
 | 
						|
    qs = models.PluginConfiguration.objects.filter(code__in=list(_plugins.keys()))
 | 
						|
    if user:
 | 
						|
        qs = qs.filter(Q(user=None) | Q(user=user))
 | 
						|
    else:
 | 
						|
        qs = qs.filter(user=None)
 | 
						|
    confs = {
 | 
						|
        v["code"]: {"conf": v["conf"], "enabled": v["enabled"]}
 | 
						|
        for v in qs.values("code", "conf", "enabled")
 | 
						|
    }
 | 
						|
    for p, v in _plugins.items():
 | 
						|
        if p not in confs:
 | 
						|
            confs[p] = {"conf": None, "enabled": False}
 | 
						|
    return confs
 | 
						|
 | 
						|
 | 
						|
def get_conf(plugin, user=None):
 | 
						|
    return get_confs(user=user)[plugin]
 | 
						|
 | 
						|
 | 
						|
def enable_conf(code, value, user):
 | 
						|
    from funkwhale_api.common import models
 | 
						|
 | 
						|
    models.PluginConfiguration.objects.update_or_create(
 | 
						|
        code=code, user=user, defaults={"enabled": value}
 | 
						|
    )
 | 
						|
 | 
						|
 | 
						|
class LibraryField(serializers.UUIDField):
 | 
						|
    def __init__(self, *args, **kwargs):
 | 
						|
        self.actor = kwargs.pop("actor")
 | 
						|
        super().__init__(*args, **kwargs)
 | 
						|
 | 
						|
    def to_internal_value(self, v):
 | 
						|
        v = super().to_internal_value(v)
 | 
						|
        if not self.actor.libraries.filter(uuid=v).first():
 | 
						|
            raise serializers.ValidationError("Invalid library id")
 | 
						|
        return v
 | 
						|
 | 
						|
 | 
						|
def get_serializer_from_conf_template(conf, source=False, user=None):
 | 
						|
    conf = copy.deepcopy(conf)
 | 
						|
    validators = {f["name"]: f.pop("validator") for f in conf if "validator" in f}
 | 
						|
    mapping = {
 | 
						|
        "url": serializers.URLField,
 | 
						|
        "boolean": serializers.BooleanField,
 | 
						|
        "text": serializers.CharField,
 | 
						|
        "long_text": serializers.CharField,
 | 
						|
        "password": serializers.CharField,
 | 
						|
        "number": serializers.IntegerField,
 | 
						|
    }
 | 
						|
 | 
						|
    for attr in ["label", "help"]:
 | 
						|
        for c in conf:
 | 
						|
            c.pop(attr, None)
 | 
						|
 | 
						|
    class Serializer(serializers.Serializer):
 | 
						|
        def __init__(self, *args, **kwargs):
 | 
						|
            super().__init__(*args, **kwargs)
 | 
						|
            for field_conf in conf:
 | 
						|
                field_kwargs = copy.copy(field_conf)
 | 
						|
                name = field_kwargs.pop("name")
 | 
						|
                self.fields[name] = mapping[field_kwargs.pop("type")](**field_kwargs)
 | 
						|
            if source:
 | 
						|
                self.fields["library"] = LibraryField(actor=user.actor)
 | 
						|
 | 
						|
    for vname, v in validators.items():
 | 
						|
        setattr(Serializer, f"validate_{vname}", v)
 | 
						|
    return Serializer
 | 
						|
 | 
						|
 | 
						|
def serialize_plugin(plugin_conf, confs):
 | 
						|
    return {
 | 
						|
        "name": plugin_conf["name"],
 | 
						|
        "label": plugin_conf["label"],
 | 
						|
        "description": plugin_conf.get("description") or None,
 | 
						|
        "user": plugin_conf.get("user", False),
 | 
						|
        "source": plugin_conf.get("source", False),
 | 
						|
        "conf": plugin_conf.get("conf", None),
 | 
						|
        "values": confs.get(plugin_conf["name"], {"conf"}).get("conf"),
 | 
						|
        "enabled": plugin_conf["name"] in confs
 | 
						|
        and confs[plugin_conf["name"]]["enabled"],
 | 
						|
        "homepage": plugin_conf["homepage"],
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
def install_dependencies(deps):
 | 
						|
    if not deps:
 | 
						|
        return
 | 
						|
    logger.info("Installing plugins dependencies %s", deps)
 | 
						|
    pip_path = os.path.join(os.path.dirname(sys.executable), "pip")
 | 
						|
    subprocess.check_call([pip_path, "install"] + deps)
 | 
						|
 | 
						|
 | 
						|
def background_task(name):
 | 
						|
    from funkwhale_api.taskapp import celery
 | 
						|
 | 
						|
    def decorator(func):
 | 
						|
        return celery.app.task(func, name=name)
 | 
						|
 | 
						|
    return decorator
 | 
						|
 | 
						|
 | 
						|
# HOOKS
 | 
						|
LISTENING_CREATED = "listening_created"
 | 
						|
"""
 | 
						|
Called when a track is being listened
 | 
						|
"""
 | 
						|
LISTENING_SYNC = "listening_sync"
 | 
						|
"""
 | 
						|
Called by the task manager to trigger listening sync
 | 
						|
"""
 | 
						|
FAVORITE_CREATED = "favorite_created"
 | 
						|
"""
 | 
						|
Called when a track is being favorited
 | 
						|
"""
 | 
						|
FAVORITE_DELETED = "favorite_deleted"
 | 
						|
"""
 | 
						|
Called when a favorited track is being unfavorited
 | 
						|
"""
 | 
						|
FAVORITE_SYNC = "favorite_sync"
 | 
						|
"""
 | 
						|
Called by the task manager to trigger favorite sync
 | 
						|
"""
 | 
						|
 | 
						|
SCAN = "scan"
 | 
						|
"""
 | 
						|
 | 
						|
"""
 | 
						|
# FILTERS
 | 
						|
PLUGINS_DEPENDENCIES = "plugins_dependencies"
 | 
						|
"""
 | 
						|
Called with an empty list, use this filter to append pip dependencies
 | 
						|
to the list for installation.
 | 
						|
"""
 | 
						|
PLUGINS_APPS = "plugins_apps"
 | 
						|
"""
 | 
						|
Called with an empty list, use this filter to append apps to INSTALLED_APPS
 | 
						|
"""
 | 
						|
MIDDLEWARES_BEFORE = "middlewares_before"
 | 
						|
"""
 | 
						|
Called with an empty list, use this filter to prepend middlewares
 | 
						|
to MIDDLEWARE
 | 
						|
"""
 | 
						|
MIDDLEWARES_AFTER = "middlewares_after"
 | 
						|
"""
 | 
						|
Called with an empty list, use this filter to append middlewares
 | 
						|
to MIDDLEWARE
 | 
						|
"""
 | 
						|
URLS = "urls"
 | 
						|
"""
 | 
						|
Called with an empty list, use this filter to register new urls and views
 | 
						|
"""
 |