Plugins infrastructure

This commit is contained in:
Agate 2020-07-03 10:59:12 +02:00
parent 9964adfbf6
commit d4028450a9
32 changed files with 1560 additions and 47 deletions

View File

@ -17,6 +17,7 @@ router = common_routers.OptionalSlashRouter()
router.register(r"settings", GlobalPreferencesViewSet, basename="settings") router.register(r"settings", GlobalPreferencesViewSet, basename="settings")
router.register(r"activity", activity_views.ActivityViewSet, "activity") router.register(r"activity", activity_views.ActivityViewSet, "activity")
router.register(r"tags", tags_views.TagViewSet, "tags") router.register(r"tags", tags_views.TagViewSet, "tags")
router.register(r"plugins", common_views.PluginViewSet, "plugins")
router.register(r"tracks", views.TrackViewSet, "tracks") router.register(r"tracks", views.TrackViewSet, "tracks")
router.register(r"uploads", views.UploadViewSet, "uploads") router.register(r"uploads", views.UploadViewSet, "uploads")
router.register(r"libraries", views.LibraryViewSet, "libraries") router.register(r"libraries", views.LibraryViewSet, "libraries")

291
api/config/plugins.py Normal file
View File

@ -0,0 +1,291 @@
import copy
import logging
import os
import subprocess
import sys
import persisting_theory
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 = {}
def get_plugin_config(
name,
user=False,
source=False,
registry=_plugins,
conf={},
description=None,
version=None,
label=None,
):
conf = {
"name": name,
"label": label or name,
"logger": logger,
"conf": conf,
"user": True if source else user,
"source": source,
"description": description,
"version": version,
}
registry[name] = conf
return conf
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, "validate_{}".format(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"],
}
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
"""
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
"""

View File

@ -46,6 +46,12 @@ logging.config.dictConfig(
# required to avoid double logging with root logger # required to avoid double logging with root logger
"propagate": False, "propagate": False,
}, },
"plugins": {
"level": LOGLEVEL,
"handlers": ["console"],
# required to avoid double logging with root logger
"propagate": False,
},
"": {"level": "WARNING", "handlers": ["console"]}, "": {"level": "WARNING", "handlers": ["console"]},
}, },
} }
@ -87,6 +93,20 @@ Path to a directory containing Funkwhale plugins. These will be imported at runt
""" """
sys.path.append(FUNKWHALE_PLUGINS_PATH) sys.path.append(FUNKWHALE_PLUGINS_PATH)
PLUGINS = [p for p in env.list("FUNKWHALE_PLUGINS", default=[]) if p]
"""
List of Funkwhale plugins to load.
"""
if PLUGINS:
logger.info("Running with the following plugins enabled: %s", ", ".join(PLUGINS))
else:
logger.info("Running with no plugins")
from .. import plugins # noqa
plugins.startup.autodiscover([p + ".funkwhale_startup" for p in PLUGINS])
DEPENDENCIES = plugins.trigger_filter(plugins.PLUGINS_DEPENDENCIES, [], enabled=True)
plugins.install_dependencies(DEPENDENCIES)
FUNKWHALE_HOSTNAME = None FUNKWHALE_HOSTNAME = None
FUNKWHALE_HOSTNAME_SUFFIX = env("FUNKWHALE_HOSTNAME_SUFFIX", default=None) FUNKWHALE_HOSTNAME_SUFFIX = env("FUNKWHALE_HOSTNAME_SUFFIX", default=None)
FUNKWHALE_HOSTNAME_PREFIX = env("FUNKWHALE_HOSTNAME_PREFIX", default=None) FUNKWHALE_HOSTNAME_PREFIX = env("FUNKWHALE_HOSTNAME_PREFIX", default=None)
@ -247,16 +267,6 @@ LOCAL_APPS = (
# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
PLUGINS = [p for p in env.list("FUNKWHALE_PLUGINS", default=[]) if p]
"""
List of Funkwhale plugins to load.
"""
if PLUGINS:
logger.info("Running with the following plugins enabled: %s", ", ".join(PLUGINS))
else:
logger.info("Running with no plugins")
ADDITIONAL_APPS = env.list("ADDITIONAL_APPS", default=[]) ADDITIONAL_APPS = env.list("ADDITIONAL_APPS", default=[])
""" """
List of Django apps to load in addition to Funkwhale plugins and apps. List of Django apps to load in addition to Funkwhale plugins and apps.
@ -265,27 +275,32 @@ INSTALLED_APPS = (
DJANGO_APPS DJANGO_APPS
+ THIRD_PARTY_APPS + THIRD_PARTY_APPS
+ LOCAL_APPS + LOCAL_APPS
+ tuple(["{}.apps.Plugin".format(p) for p in PLUGINS])
+ tuple(ADDITIONAL_APPS) + tuple(ADDITIONAL_APPS)
+ tuple(plugins.trigger_filter(plugins.PLUGINS_APPS, [], enabled=True))
) )
# MIDDLEWARE CONFIGURATION # MIDDLEWARE CONFIGURATION
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
ADDITIONAL_MIDDLEWARES_BEFORE = env.list("ADDITIONAL_MIDDLEWARES_BEFORE", default=[]) ADDITIONAL_MIDDLEWARES_BEFORE = env.list("ADDITIONAL_MIDDLEWARES_BEFORE", default=[])
MIDDLEWARE = tuple(ADDITIONAL_MIDDLEWARES_BEFORE) + ( MIDDLEWARE = (
"django.middleware.security.SecurityMiddleware", tuple(plugins.trigger_filter(plugins.MIDDLEWARES_BEFORE, [], enabled=True))
"django.middleware.clickjacking.XFrameOptionsMiddleware", + tuple(ADDITIONAL_MIDDLEWARES_BEFORE)
"corsheaders.middleware.CorsMiddleware", + (
# needs to be before SPA middleware "django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django.middleware.common.CommonMiddleware", "corsheaders.middleware.CorsMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", # needs to be before SPA middleware
# /end "django.contrib.sessions.middleware.SessionMiddleware",
"funkwhale_api.common.middleware.SPAFallbackMiddleware", "django.middleware.common.CommonMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", # /end
"funkwhale_api.users.middleware.RecordActivityMiddleware", "funkwhale_api.common.middleware.SPAFallbackMiddleware",
"funkwhale_api.common.middleware.ThrottleStatusMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"funkwhale_api.users.middleware.RecordActivityMiddleware",
"funkwhale_api.common.middleware.ThrottleStatusMiddleware",
)
+ tuple(plugins.trigger_filter(plugins.MIDDLEWARES_AFTER, [], enabled=True))
) )
# DEBUG # DEBUG

View File

@ -8,7 +8,9 @@ from django.conf.urls.static import static
from funkwhale_api.common import admin from funkwhale_api.common import admin
from django.views import defaults as default_views from django.views import defaults as default_views
from config import plugins
plugins_patterns = plugins.trigger_filter(plugins.URLS, [], enabled=True)
urlpatterns = [ urlpatterns = [
# Django Admin, use {% url 'admin:index' %} # Django Admin, use {% url 'admin:index' %}
url(settings.ADMIN_URL, admin.site.urls), url(settings.ADMIN_URL, admin.site.urls),
@ -21,8 +23,7 @@ urlpatterns = [
), ),
url(r"^api/v1/auth/", include("funkwhale_api.users.rest_auth_urls")), url(r"^api/v1/auth/", include("funkwhale_api.users.rest_auth_urls")),
url(r"^accounts/", include("allauth.urls")), url(r"^accounts/", include("allauth.urls")),
# Your stuff: custom urls includes go here ] + plugins_patterns
]
if settings.DEBUG: if settings.DEBUG:
# This allows the error pages to be debugged during development, just visit # This allows the error pages to be debugged during development, just visit

View File

@ -4,6 +4,7 @@ import sys
from . import base from . import base
from . import library # noqa from . import library # noqa
from . import media # noqa from . import media # noqa
from . import plugins # noqa
from . import users # noqa from . import users # noqa
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError

View File

@ -0,0 +1,35 @@
import os
import subprocess
import sys
import click
from django.conf import settings
from . import base
@base.cli.group()
def plugins():
"""Manage plugins"""
pass
@plugins.command("install")
@click.argument("plugin", nargs=-1)
def install(plugin):
"""
Install a plugin from a given URL (zip, pip or git are supported)
"""
if not plugin:
return click.echo("No plugin provided")
click.echo("Installing plugins…")
pip_install(list(plugin), settings.FUNKWHALE_PLUGINS_PATH)
def pip_install(deps, target):
if not deps:
return
pip_path = os.path.join(os.path.dirname(sys.executable), "pip")
subprocess.check_call([pip_path, "install", "-t", target] + deps)

View File

@ -1,4 +1,7 @@
from django.apps import AppConfig, apps from django.apps import AppConfig, apps
from django.conf import settings
from config import plugins
from . import mutations from . import mutations
from . import utils from . import utils
@ -13,3 +16,4 @@ class CommonConfig(AppConfig):
app_names = [app.name for app in apps.app_configs.values()] app_names = [app.name for app in apps.app_configs.values()]
mutations.registry.autodiscover(app_names) mutations.registry.autodiscover(app_names)
utils.monkey_patch_request_build_absolute_uri() utils.monkey_patch_request_build_absolute_uri()
plugins.startup.autodiscover([p + ".funkwhale_ready" for p in settings.PLUGINS])

View File

@ -35,3 +35,12 @@ class CommonFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
class Meta: class Meta:
model = "common.Content" model = "common.Content"
@registry.register
class PluginConfiguration(NoUpdateOnCreate, factory.django.DjangoModelFactory):
code = "test"
conf = {"foo": "bar"}
class Meta:
model = "common.PluginConfiguration"

View File

@ -11,6 +11,7 @@ from django import http
from django.conf import settings from django.conf import settings
from django.core.cache import caches from django.core.cache import caches
from django.middleware import csrf from django.middleware import csrf
from django.contrib import auth
from django import urls from django import urls
from rest_framework import views from rest_framework import views
@ -282,6 +283,25 @@ def monkey_patch_rest_initialize_request():
monkey_patch_rest_initialize_request() monkey_patch_rest_initialize_request()
def monkey_patch_auth_get_user():
"""
We need an actor on our users for many endpoints, so we monkey patch
auth.get_user to create it if it's missing
"""
original = auth.get_user
def replacement(request):
r = original(request)
if not r.is_anonymous and not r.actor:
r.create_actor()
return r
setattr(auth, "get_user", replacement)
monkey_patch_auth_get_user()
class ThrottleStatusMiddleware: class ThrottleStatusMiddleware:
""" """
Include useful information regarding throttling in API responses to Include useful information regarding throttling in API responses to

View File

@ -0,0 +1,37 @@
# Generated by Django 3.0.8 on 2020-07-01 13:17
from django.conf import settings
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('common', '0007_auto_20200116_1610'),
]
operations = [
migrations.AlterField(
model_name='attachment',
name='url',
field=models.URLField(blank=True, max_length=500, null=True),
),
migrations.CreateModel(
name='PluginConfiguration',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=100)),
('conf', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
('enabled', models.BooleanField(default=False)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='plugins', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'code')},
},
),
]

View File

@ -363,3 +363,24 @@ def remove_attached_content(sender, instance, **kwargs):
getattr(instance, field).delete() getattr(instance, field).delete()
except Content.DoesNotExist: except Content.DoesNotExist:
pass pass
class PluginConfiguration(models.Model):
"""
Store plugin configuration in DB
"""
code = models.CharField(max_length=100)
user = models.ForeignKey(
"users.User",
related_name="plugins",
on_delete=models.CASCADE,
null=True,
blank=True,
)
conf = JSONField(null=True, blank=True)
enabled = models.BooleanField(default=False)
creation_date = models.DateTimeField(default=timezone.now)
class Meta:
unique_together = ("user", "code")

View File

@ -12,6 +12,8 @@ from rest_framework import response
from rest_framework import views from rest_framework import views
from rest_framework import viewsets from rest_framework import viewsets
from config import plugins
from funkwhale_api.users.oauth import permissions as oauth_permissions from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters from . import filters
@ -210,3 +212,102 @@ class TextPreviewView(views.APIView):
) )
} }
return response.Response(data, status=200) return response.Response(data, status=200)
class PluginViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
required_scope = "plugins"
serializer_class = serializers.serializers.Serializer
queryset = models.PluginConfiguration.objects.none()
def list(self, request, *args, **kwargs):
user = request.user
user_plugins = [p for p in plugins._plugins.values() if p["user"] is True]
return response.Response(
[
plugins.serialize_plugin(p, confs=plugins.get_confs(user=user))
for p in user_plugins
]
)
def retrieve(self, request, *args, **kwargs):
user = request.user
user_plugin = [
p
for p in plugins._plugins.values()
if p["user"] is True and p["name"] == kwargs["pk"]
]
if not user_plugin:
return response.Response(status=404)
return response.Response(
plugins.serialize_plugin(user_plugin[0], confs=plugins.get_confs(user=user))
)
def post(self, request, *args, **kwargs):
return self.create(request, *args, **kwargs)
def create(self, request, *args, **kwargs):
user = request.user
confs = plugins.get_confs(user=user)
user_plugin = [
p
for p in plugins._plugins.values()
if p["user"] is True and p["name"] == kwargs["pk"]
]
if kwargs["pk"] not in confs:
return response.Response(status=404)
plugins.set_conf(kwargs["pk"], request.data, user)
return response.Response(
plugins.serialize_plugin(user_plugin[0], confs=plugins.get_confs(user=user))
)
def delete(self, request, *args, **kwargs):
user = request.user
confs = plugins.get_confs(user=user)
if kwargs["pk"] not in confs:
return response.Response(status=404)
user.plugins.filter(code=kwargs["pk"]).delete()
return response.Response(status=204)
@action(detail=True, methods=["post"])
def enable(self, request, *args, **kwargs):
user = request.user
if kwargs["pk"] not in plugins._plugins:
return response.Response(status=404)
plugins.enable_conf(kwargs["pk"], True, user)
return response.Response({}, status=200)
@action(detail=True, methods=["post"])
def disable(self, request, *args, **kwargs):
user = request.user
if kwargs["pk"] not in plugins._plugins:
return response.Response(status=404)
plugins.enable_conf(kwargs["pk"], False, user)
return response.Response({}, status=200)
@action(detail=True, methods=["post"])
def scan(self, request, *args, **kwargs):
user = request.user
if kwargs["pk"] not in plugins._plugins:
return response.Response(status=404)
conf = plugins.get_conf(kwargs["pk"], user=user)
if not conf["enabled"]:
return response.Response(status=405)
library = request.user.actor.libraries.get(uuid=conf["conf"]["library"])
hook = [
hook
for p, hook in plugins._hooks.get(plugins.SCAN, [])
if p == kwargs["pk"]
]
if not hook:
return response.Response(status=405)
hook[0](library=library, conf=conf["conf"])
return response.Response({}, status=200)

View File

@ -0,0 +1,39 @@
from config import plugins
from .funkwhale_startup import PLUGIN
from . import scrobbler
# https://listenbrainz.org/lastfm-proxy
DEFAULT_SCROBBLER_URL = "http://post.audioscrobbler.com"
@plugins.register_hook(plugins.LISTENING_CREATED, PLUGIN)
def forward_to_scrobblers(listening, conf, **kwargs):
if not conf:
raise plugins.Skip()
username = conf.get("username")
password = conf.get("password")
url = conf.get("url", DEFAULT_SCROBBLER_URL) or DEFAULT_SCROBBLER_URL
if username and password:
PLUGIN["logger"].info("Forwarding scrobbler to %s", url)
session = plugins.get_session()
session_key, now_playing_url, scrobble_url = scrobbler.handshake_v1(
session=session, url=url, username=username, password=password
)
scrobbler.submit_now_playing_v1(
session=session,
track=listening.track,
session_key=session_key,
now_playing_url=now_playing_url,
)
scrobbler.submit_scrobble_v1(
session=session,
track=listening.track,
scrobble_time=listening.creation_date,
session_key=session_key,
scrobble_url=scrobble_url,
)
else:
PLUGIN["logger"].debug("No scrobbler configuration for user, skipping")

View File

@ -0,0 +1,27 @@
from config import plugins
PLUGIN = plugins.get_plugin_config(
name="scrobbler",
label="Scrobbler",
description="A plugin that enables scrobbling to ListenBrainz and Last.fm",
version="0.1",
user=True,
conf=[
{
"name": "url",
"type": "url",
"allow_null": True,
"allow_blank": True,
"required": False,
"label": "URL of the scrobbler service",
"help": (
"Suggested choices:\n\n"
"- LastFM (default if left empty): http://post.audioscrobbler.com\n"
"- ListenBrainz: http://proxy.listenbrainz.org/\n"
"- Libre.fm: http://turtle.libre.fm/"
),
},
{"name": "username", "type": "text", "label": "Your scrobbler username"},
{"name": "password", "type": "password", "label": "Your scrobbler password"},
],
)

View File

@ -0,0 +1,98 @@
import hashlib
import time
# https://github.com/jlieth/legacy-scrobbler
from .funkwhale_startup import PLUGIN
class ScrobblerException(Exception):
pass
def handshake_v1(session, url, username, password):
timestamp = str(int(time.time())).encode("utf-8")
password_hash = hashlib.md5(password.encode("utf-8")).hexdigest()
auth = hashlib.md5(password_hash.encode("utf-8") + timestamp).hexdigest()
params = {
"hs": "true",
"p": "1.2",
"c": PLUGIN["name"],
"v": PLUGIN["version"],
"u": username,
"t": timestamp,
"a": auth,
}
PLUGIN["logger"].debug(
"Performing scrobbler handshake for username %s at %s", username, url
)
handshake_response = session.get(url, params=params)
# process response
result = handshake_response.text.split("\n")
if len(result) >= 4 and result[0] == "OK":
session_key = result[1]
nowplaying_url = result[2]
scrobble_url = result[3]
elif result[0] == "BANNED":
raise ScrobblerException("BANNED")
elif result[0] == "BADAUTH":
raise ScrobblerException("BADAUTH")
elif result[0] == "BADTIME":
raise ScrobblerException("BADTIME")
else:
raise ScrobblerException(handshake_response.text)
PLUGIN["logger"].debug("Handshake successful, scrobble url: %s", scrobble_url)
return session_key, nowplaying_url, scrobble_url
def submit_scrobble_v1(session, scrobble_time, track, session_key, scrobble_url):
payload = get_scrobble_payload(track, scrobble_time)
PLUGIN["logger"].debug("Sending scrobble with payload %s", payload)
payload["s"] = session_key
response = session.post(scrobble_url, payload)
response.raise_for_status()
if response.text.startswith("OK"):
return
elif response.text.startswith("BADSESSION"):
raise ScrobblerException("Remote server says the session is invalid")
else:
raise ScrobblerException(response.text)
PLUGIN["logger"].debug("Scrobble successfull!")
def submit_now_playing_v1(session, track, session_key, now_playing_url):
payload = get_scrobble_payload(track, date=None, suffix="")
PLUGIN["logger"].debug("Sending now playing with payload %s", payload)
payload["s"] = session_key
response = session.post(now_playing_url, payload)
response.raise_for_status()
if response.text.startswith("OK"):
return
elif response.text.startswith("BADSESSION"):
raise ScrobblerException("Remote server says the session is invalid")
else:
raise ScrobblerException(response.text)
PLUGIN["logger"].debug("Now playing successfull!")
def get_scrobble_payload(track, date, suffix="[0]"):
"""
Documentation available at https://web.archive.org/web/20190531021725/https://www.last.fm/api/submissions
"""
upload = track.uploads.filter(duration__gte=0).first()
data = {
"a{}".format(suffix): track.artist.name,
"t{}".format(suffix): track.title,
"l{}".format(suffix): upload.duration if upload else 0,
"b{}".format(suffix): (track.album.title if track.album else "") or "",
"n{}".format(suffix): track.position or "",
"m{}".format(suffix): str(track.mbid) or "",
"o{}".format(suffix): "P", # Source: P = chosen by user
}
if date:
data["i{}".format(suffix)] = int(date.timestamp())
return data

View File

@ -2,6 +2,8 @@ from rest_framework import mixins, viewsets
from django.db.models import Prefetch from django.db.models import Prefetch
from config import plugins
from funkwhale_api.activity import record from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions from funkwhale_api.common import fields, permissions
from funkwhale_api.music.models import Track from funkwhale_api.music.models import Track
@ -39,6 +41,11 @@ class ListeningViewSet(
def perform_create(self, serializer): def perform_create(self, serializer):
r = super().perform_create(serializer) r = super().perform_create(serializer)
plugins.trigger_hook(
plugins.LISTENING_CREATED,
listening=serializer.instance,
confs=plugins.get_confs(self.request.user),
)
record.send(serializer.instance) record.send(serializer.instance)
return r return r

View File

@ -655,9 +655,7 @@ def handle_modified(event, stdout, library, in_place, **kwargs):
and to_update.track.attributed_to != library.actor and to_update.track.attributed_to != library.actor
): ):
stdout.write( stdout.write(
" Cannot update track metadata, track belongs to someone else".format( " Cannot update track metadata, track belongs to someone else"
to_update.pk
)
) )
return return
else: else:
@ -777,9 +775,7 @@ def check_upload(stdout, upload):
) )
if upload.library.actor_id != upload.track.attributed_to_id: if upload.library.actor_id != upload.track.attributed_to_id:
stdout.write( stdout.write(
" Cannot update track metadata, track belongs to someone else".format( " Cannot update track metadata, track belongs to someone else"
upload.pk
)
) )
else: else:
track = models.Track.objects.select_related("artist", "album__artist").get( track = models.Track.objects.select_related("artist", "album__artist").get(

View File

@ -103,7 +103,9 @@ class UserQuerySet(models.QuerySet):
user=models.OuterRef("id"), primary=True user=models.OuterRef("id"), primary=True
).values("verified")[:1] ).values("verified")[:1]
subquery = models.Subquery(verified_emails) subquery = models.Subquery(verified_emails)
return qs.annotate(has_verified_primary_email=subquery) return qs.annotate(has_verified_primary_email=subquery).prefetch_related(
"plugins"
)
class UserManager(BaseUserManager): class UserManager(BaseUserManager):

View File

@ -23,6 +23,7 @@ BASE_SCOPES = [
Scope("notifications", "Access personal notifications"), Scope("notifications", "Access personal notifications"),
Scope("security", "Access security settings"), Scope("security", "Access security settings"),
Scope("reports", "Access reports"), Scope("reports", "Access reports"),
Scope("plugins", "Access plugins"),
# Privileged scopes that require specific user permissions # Privileged scopes that require specific user permissions
Scope("instance:settings", "Access instance settings"), Scope("instance:settings", "Access instance settings"),
Scope("instance:users", "Access local user accounts"), Scope("instance:users", "Access local user accounts"),
@ -81,7 +82,12 @@ COMMON_SCOPES = ANONYMOUS_SCOPES | {
"write:listenings", "write:listenings",
} }
LOGGED_IN_SCOPES = COMMON_SCOPES | {"read:security", "write:security"} LOGGED_IN_SCOPES = COMMON_SCOPES | {
"read:security",
"write:security",
"read:plugins",
"write:plugins",
}
# We don't allow admin access for oauth apps yet # We don't allow admin access for oauth apps yet
OAUTH_APP_SCOPES = COMMON_SCOPES OAUTH_APP_SCOPES = COMMON_SCOPES

View File

@ -1,7 +1,7 @@
[flake8] [flake8]
max-line-length = 120 max-line-length = 120
exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,tests/data,tests/music/conftest.py exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,tests/data,tests/music/conftest.py
ignore = F405,W503,E203 ignore = F405,W503,E203,E741
[isort] [isort]
skip_glob = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules skip_glob = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
@ -35,3 +35,4 @@ env =
EXTERNAL_MEDIA_PROXY_ENABLED=true EXTERNAL_MEDIA_PROXY_ENABLED=true
DISABLE_PASSWORD_VALIDATORS=false DISABLE_PASSWORD_VALIDATORS=false
DISABLE_PASSWORD_VALIDATORS=false DISABLE_PASSWORD_VALIDATORS=false
FUNKWHALE_PLUGINS=

View File

@ -67,11 +67,11 @@ def test_domain_create(superuser_api_client, mocker):
"funkwhale_api.federation.tasks.update_domain_nodeinfo" "funkwhale_api.federation.tasks.update_domain_nodeinfo"
) )
url = reverse("api:v1:manage:federation:domains-list") url = reverse("api:v1:manage:federation:domains-list")
response = superuser_api_client.post(url, {"name": "test.federation"}) response = superuser_api_client.post(url, {"name": "test.domain"})
assert response.status_code == 201 assert response.status_code == 201
assert federation_models.Domain.objects.filter(pk="test.federation").exists() assert federation_models.Domain.objects.filter(pk="test.domain").exists()
update_domain_nodeinfo.assert_called_once_with(domain_name="test.federation") update_domain_nodeinfo.assert_called_once_with(domain_name="test.domain")
def test_domain_update_allowed(superuser_api_client, factories): def test_domain_update_allowed(superuser_api_client, factories):
@ -85,6 +85,8 @@ def test_domain_update_allowed(superuser_api_client, factories):
def test_domain_update_cannot_change_name(superuser_api_client, factories): def test_domain_update_cannot_change_name(superuser_api_client, factories):
superuser_api_client.user.create_actor()
domain = factories["federation.Domain"]() domain = factories["federation.Domain"]()
old_name = domain.name old_name = domain.name
url = reverse("api:v1:manage:federation:domains-detail", kwargs={"pk": old_name}) url = reverse("api:v1:manage:federation:domains-detail", kwargs={"pk": old_name})
@ -96,7 +98,9 @@ def test_domain_update_cannot_change_name(superuser_api_client, factories):
assert domain.name == old_name assert domain.name == old_name
# changing the pk of a model and saving results in a new DB entry in django, # changing the pk of a model and saving results in a new DB entry in django,
# so we check that no other entry was created # so we check that no other entry was created
assert domain.__class__.objects.count() == 1 assert (
domain.__class__.objects.count() == 2
) # one for pod domain, and the other one
def test_domain_nodeinfo(factories, superuser_api_client, mocker): def test_domain_nodeinfo(factories, superuser_api_client, mocker):
@ -131,8 +135,8 @@ def test_actor_list(factories, superuser_api_client, settings):
assert response.status_code == 200 assert response.status_code == 200
assert response.data["count"] == 1 assert response.data["count"] == 2
assert response.data["results"][0]["id"] == actor.id assert response.data["results"][1]["id"] == actor.id
def test_actor_detail(factories, superuser_api_client): def test_actor_detail(factories, superuser_api_client):

View File

@ -0,0 +1,424 @@
import os
import sys
import pytest
from django.urls import reverse
from rest_framework import serializers
from funkwhale_api.common import models
from config import plugins
@pytest.fixture(autouse=True)
def _plugins():
plugins._filters.clear()
plugins._hooks.clear()
plugins._plugins.clear()
yield
plugins._filters.clear()
plugins._hooks.clear()
plugins._plugins.clear()
def test_register_filter():
filters = {}
plugin_config = plugins.get_plugin_config("test", {})
def handler(value, conf):
return value + 1
plugins.register_filter("test_filter", plugin_config, filters)(handler)
plugins.register_filter("test_filter", plugin_config, filters)(handler)
assert len(filters["test_filter"]) == 2
assert plugins.trigger_filter("test_filter", 1, confs={}, registry=filters) == 3
def test_register_hook(mocker):
hooks = {}
plugin_config = plugins.get_plugin_config("test", {})
mock = mocker.Mock()
def handler(conf):
mock()
plugins.register_hook("test_hook", plugin_config, hooks)(handler)
plugins.register_hook("test_hook", plugin_config, hooks)(handler)
plugins.trigger_hook("test_hook", confs={}, registry=hooks)
assert mock.call_count == 2
assert len(hooks["test_hook"]) == 2
def test_get_plugin_conf():
_plugins = {}
plugin_config = plugins.get_plugin_config(
"test", description="Hello", registry=_plugins
)
assert plugin_config["name"] == "test"
assert plugin_config["description"] == "Hello"
assert plugin_config["user"] is False
assert _plugins == {
"test": plugin_config,
}
def test_set_plugin_conf_validates():
_plugins = {}
plugins.get_plugin_config(
"test", registry=_plugins, conf=[{"name": "foo", "type": "boolean"}]
)
with pytest.raises(serializers.ValidationError):
plugins.set_conf("test", {"foo": "noop"}, registry=_plugins)
def test_set_plugin_conf_valid():
_plugins = {}
plugins.get_plugin_config(
"test", registry=_plugins, conf=[{"name": "foo", "type": "boolean"}]
)
plugins.set_conf("test", {"foo": True}, registry=_plugins)
conf = models.PluginConfiguration.objects.latest("id")
assert conf.code == "test"
assert conf.conf == {"foo": True}
assert conf.user is None
def test_set_plugin_conf_valid_user(factories):
user = factories["users.User"]()
_plugins = {}
plugins.get_plugin_config(
"test", registry=_plugins, conf=[{"name": "foo", "type": "boolean"}]
)
plugins.set_conf("test", {"foo": True}, user=user, registry=_plugins)
conf = models.PluginConfiguration.objects.latest("id")
assert conf.code == "test"
assert conf.conf == {"foo": True}
assert conf.user == user
def test_get_confs(factories):
plugins.get_plugin_config("test1")
plugins.get_plugin_config("test2")
factories["common.PluginConfiguration"](code="test1", conf={"hello": "world"})
factories["common.PluginConfiguration"](code="test2", conf={"foo": "bar"})
assert plugins.get_confs() == {
"test1": {"conf": {"hello": "world"}, "enabled": False},
"test2": {"conf": {"foo": "bar"}, "enabled": False},
}
def test_get_confs_user(factories):
plugins.get_plugin_config("test1")
plugins.get_plugin_config("test2")
plugins.get_plugin_config("test3")
user1 = factories["users.User"]()
user2 = factories["users.User"]()
factories["common.PluginConfiguration"](code="test1", conf={"hello": "world"})
factories["common.PluginConfiguration"](code="test2", conf={"foo": "bar"})
factories["common.PluginConfiguration"](
code="test3", conf={"user": True}, user=user1
)
factories["common.PluginConfiguration"](
code="test4", conf={"user": False}, user=user2
)
assert plugins.get_confs(user=user1) == {
"test1": {"conf": {"hello": "world"}, "enabled": False},
"test2": {"conf": {"foo": "bar"}, "enabled": False},
"test3": {"conf": {"user": True}, "enabled": False},
}
def test_filter_is_called_with_plugin_conf(mocker, factories):
plugins.get_plugin_config("test1",)
plugins.get_plugin_config("test2",)
factories["common.PluginConfiguration"](code="test1", enabled=True)
factories["common.PluginConfiguration"](
code="test2", conf={"foo": "baz"}, enabled=True
)
confs = plugins.get_confs()
filters = {}
plugin_config1 = plugins.get_plugin_config("test1", {})
plugin_config2 = plugins.get_plugin_config("test2", {})
handler1 = mocker.Mock()
handler2 = mocker.Mock()
plugins.register_filter("test_filter", plugin_config1, filters)(handler1)
plugins.register_filter("test_filter", plugin_config2, filters)(handler2)
plugins.trigger_filter("test_filter", 1, confs=confs, registry=filters)
handler1.assert_called_once_with(1, conf=confs["test1"])
handler2.assert_called_once_with(handler1.return_value, conf=confs["test2"])
def test_get_serializer_from_conf_template():
template = [
{
"name": "enabled",
"type": "boolean",
"default": True,
"label": "Enable plugin",
},
{
"name": "api_url",
"type": "url",
"label": "URL of the scrobbler API",
"validator": lambda self, v: v + "/test",
},
]
serializer_class = plugins.get_serializer_from_conf_template(template)
data = {
"enabled": True,
"api_url": "http://hello.world",
}
serializer = serializer_class(data=data)
assert serializer.is_valid(raise_exception=True) is True
assert serializer.validated_data == {
"enabled": True,
"api_url": "http://hello.world/test",
}
def test_serialize_plugin():
plugin = plugins.get_plugin_config(
name="test_plugin",
description="Hello world",
conf=[{"name": "foo", "type": "boolean"}],
)
expected = {
"name": "test_plugin",
"enabled": False,
"description": "Hello world",
"conf": [{"name": "foo", "type": "boolean"}],
"user": False,
"source": False,
"label": "test_plugin",
"values": None,
}
assert plugins.serialize_plugin(plugin, plugins.get_confs()) == expected
def test_serialize_plugin_user(factories):
user = factories["users.User"]()
plugin = plugins.get_plugin_config(
name="test_plugin",
description="Hello world",
conf=[{"name": "foo", "type": "boolean"}],
user=True,
)
expected = {
"name": "test_plugin",
"enabled": False,
"description": "Hello world",
"conf": [{"name": "foo", "type": "boolean"}],
"user": True,
"source": False,
"label": "test_plugin",
"values": None,
}
assert plugins.serialize_plugin(plugin, plugins.get_confs(user)) == expected
def test_serialize_plugin_user_enabled(factories):
user = factories["users.User"]()
plugin = plugins.get_plugin_config(
name="test_plugin",
description="Hello world",
conf=[{"name": "foo", "type": "boolean"}],
user=True,
)
factories["common.PluginConfiguration"](
code="test_plugin", user=user, enabled=True, conf={"foo": "bar"}
)
expected = {
"name": "test_plugin",
"enabled": True,
"description": "Hello world",
"conf": [{"name": "foo", "type": "boolean"}],
"user": True,
"source": False,
"label": "test_plugin",
"values": {"foo": "bar"},
}
assert plugins.serialize_plugin(plugin, plugins.get_confs(user)) == expected
def test_can_list_user_plugins(logged_in_api_client):
plugin = plugins.get_plugin_config(
name="test_plugin",
description="Hello world",
conf=[{"name": "foo", "type": "boolean"}],
user=True,
)
plugins.get_plugin_config(name="test_plugin2", user=False)
url = reverse("api:v1:plugins-list")
response = logged_in_api_client.get(url)
assert response.status_code == 200
assert response.data == [
plugins.serialize_plugin(plugin, plugins.get_confs(logged_in_api_client.user))
]
def test_can_retrieve_user_plugin(logged_in_api_client):
plugin = plugins.get_plugin_config(
name="test_plugin",
description="Hello world",
conf=[{"name": "foo", "type": "boolean"}],
user=True,
)
plugins.get_plugin_config(name="test_plugin2", user=False)
url = reverse("api:v1:plugins-detail", kwargs={"pk": "test_plugin"})
response = logged_in_api_client.get(url)
assert response.status_code == 200
assert response.data == plugins.serialize_plugin(
plugin, plugins.get_confs(logged_in_api_client.user)
)
def test_can_update_user_plugin(logged_in_api_client):
plugin = plugins.get_plugin_config(
name="test_plugin",
description="Hello world",
conf=[{"name": "foo", "type": "boolean"}],
user=True,
)
plugins.get_plugin_config(name="test_plugin2", user=False)
url = reverse("api:v1:plugins-detail", kwargs={"pk": "test_plugin"})
response = logged_in_api_client.post(url, {"foo": True})
assert response.status_code == 200
assert logged_in_api_client.user.plugins.latest("id").conf == {"foo": True}
assert response.data == plugins.serialize_plugin(
plugin, plugins.get_confs(logged_in_api_client.user)
)
def test_can_destroy_user_plugin(logged_in_api_client):
plugins.get_plugin_config(
name="test_plugin",
description="Hello world",
conf=[{"name": "foo", "type": "boolean"}],
user=True,
)
plugins.set_conf("test_plugin", {"foo": True}, user=logged_in_api_client.user)
plugins.get_plugin_config(name="test_plugin2", user=False)
url = reverse("api:v1:plugins-detail", kwargs={"pk": "test_plugin"})
response = logged_in_api_client.delete(url, {"enabled": True})
assert response.status_code == 204
with pytest.raises(models.PluginConfiguration.DoesNotExist):
assert logged_in_api_client.user.plugins.latest("id")
def test_can_enable_user_plugin(logged_in_api_client):
plugins.get_plugin_config(
name="test_plugin",
description="Hello world",
conf=[{"name": "foo", "type": "boolean"}],
user=True,
)
plugins.set_conf("test_plugin", {"foo": True}, user=logged_in_api_client.user)
url = reverse("api:v1:plugins-enable", kwargs={"pk": "test_plugin"})
response = logged_in_api_client.post(url)
assert response.status_code == 200
assert logged_in_api_client.user.plugins.latest("id").enabled is True
def test_can_disable_user_plugin(logged_in_api_client):
plugins.get_plugin_config(
name="test_plugin",
description="Hello world",
conf=[{"name": "foo", "type": "boolean"}],
user=True,
)
plugins.set_conf("test_plugin", {"foo": True}, user=logged_in_api_client.user)
url = reverse("api:v1:plugins-disable", kwargs={"pk": "test_plugin"})
response = logged_in_api_client.post(url)
assert response.status_code == 200
assert logged_in_api_client.user.plugins.latest("id").enabled is False
def test_can_install_dependencies(mocker):
dependencies = ["depa==12", "depb"]
check_call = mocker.patch("subprocess.check_call")
expected = [
os.path.join(os.path.dirname(sys.executable), "pip"),
"install",
] + dependencies
plugins.install_dependencies(dependencies)
check_call.assert_called_once_with(expected)
def test_set_plugin_source_conf_invalid(factories):
user = factories["users.User"]()
_plugins = {}
plugins.get_plugin_config(
"test",
source=True,
registry=_plugins,
conf=[{"name": "foo", "type": "boolean"}],
)
with pytest.raises(serializers.ValidationError):
plugins.set_conf("test", {"foo": True}, user=user, registry=_plugins)
def test_set_plugin_source_conf_valid(factories):
library = factories["music.Library"](actor__local=True)
_plugins = {}
plugins.get_plugin_config(
"test",
source=True,
registry=_plugins,
conf=[{"name": "foo", "type": "boolean"}],
)
plugins.set_conf(
"test",
{"foo": True, "library": library.uuid},
user=library.actor.user,
registry=_plugins,
)
conf = models.PluginConfiguration.objects.latest("id")
assert conf.code == "test"
assert conf.conf == {"foo": True, "library": str(library.uuid)}
assert conf.user == library.actor.user
def test_can_trigger_scan(logged_in_api_client, mocker, factories):
library = factories["music.Library"](actor=logged_in_api_client.user.create_actor())
plugin = plugins.get_plugin_config(
name="test_plugin", description="Hello world", conf=[], source=True,
)
handler = mocker.Mock()
plugins.register_hook(plugins.SCAN, plugin)(handler)
plugins.set_conf(
"test_plugin", {"library": library.uuid}, user=logged_in_api_client.user
)
url = reverse("api:v1:plugins-scan", kwargs={"pk": "test_plugin"})
plugins.enable_conf("test_plugin", True, logged_in_api_client.user)
response = logged_in_api_client.post(url)
assert response.status_code == 200
handler.assert_called_once_with(
library=library, conf={"library": str(library.uuid)}
)

View File

@ -34,6 +34,9 @@ from funkwhale_api.users.oauth import scopes
"write:listenings", "write:listenings",
"read:security", "read:security",
"write:security", "write:security",
"write:listenings",
"read:plugins",
"write:plugins",
"read:instance:policies", "read:instance:policies",
"write:instance:policies", "write:instance:policies",
"read:instance:accounts", "read:instance:accounts",
@ -85,6 +88,8 @@ from funkwhale_api.users.oauth import scopes
"write:listenings", "write:listenings",
"read:security", "read:security",
"write:security", "write:security",
"read:plugins",
"write:plugins",
"read:instance:policies", "read:instance:policies",
"write:instance:policies", "write:instance:policies",
"read:instance:accounts", "read:instance:accounts",
@ -132,6 +137,8 @@ from funkwhale_api.users.oauth import scopes
"write:listenings", "write:listenings",
"read:security", "read:security",
"write:security", "write:security",
"read:plugins",
"write:plugins",
"read:instance:policies", "read:instance:policies",
"write:instance:policies", "write:instance:policies",
"read:instance:accounts", "read:instance:accounts",
@ -173,6 +180,8 @@ from funkwhale_api.users.oauth import scopes
"write:listenings", "write:listenings",
"read:security", "read:security",
"write:security", "write:security",
"read:plugins",
"write:plugins",
}, },
), ),
], ],

View File

@ -23,6 +23,7 @@ import datetime
from shutil import copyfile from shutil import copyfile
sys.path.insert(0, os.path.abspath("../api")) sys.path.insert(0, os.path.abspath("../api"))
sys.path.insert(0, os.path.abspath("../api/config"))
import funkwhale_api # NOQA import funkwhale_api # NOQA
@ -30,9 +31,9 @@ FUNKWHALE_CONFIG = {
"FUNKWHALE_URL": "mypod.funkwhale", "FUNKWHALE_URL": "mypod.funkwhale",
"FUNKWHAL_PROTOCOL": "https", "FUNKWHAL_PROTOCOL": "https",
"DATABASE_URL": "postgres://localhost:5432/db", "DATABASE_URL": "postgres://localhost:5432/db",
"AWS_ACCESS_KEY_ID": 'my_access_key', "AWS_ACCESS_KEY_ID": "my_access_key",
"AWS_SECRET_ACCESS_KEY": 'my_secret_key', "AWS_SECRET_ACCESS_KEY": "my_secret_key",
"AWS_STORAGE_BUCKET_NAME": 'my_bucket', "AWS_STORAGE_BUCKET_NAME": "my_bucket",
} }
for key, value in FUNKWHALE_CONFIG.items(): for key, value in FUNKWHALE_CONFIG.items():
os.environ[key] = value os.environ[key] = value
@ -46,7 +47,7 @@ for key, value in FUNKWHALE_CONFIG.items():
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # ones.
extensions = ["sphinx.ext.graphviz", "sphinx.ext.autodoc"] extensions = ["sphinx.ext.graphviz", "sphinx.ext.autodoc"]
autodoc_mock_imports = ["celery", "django_auth_ldap", "ldap"] autodoc_mock_imports = ["celery", "django_auth_ldap", "ldap", "persisting_theory", "rest_framework"]
add_module_names = False add_module_names = False
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"] templates_path = ["_templates"]

View File

@ -13,5 +13,6 @@ Reference
architecture architecture
../api ../api
./authentication ./authentication
./plugins
../federation/index ../federation/index
subsonic subsonic

165
docs/developers/plugins.rst Normal file
View File

@ -0,0 +1,165 @@
Funkwhale plugins
=================
Starting with Funkwhale 1.0, it is now possible to implement new features
via plugins.
Some plugins are maintained by the Funkwhale team (e.g. this is the case of the ``scrobbler`` plugin),
or by third-parties.
Installing a plugin
-------------------
To install a plugin, ensure its directory is present in the ``FUNKWHALE_PLUGINS_PATH`` directory.
Then, add its name to the ``FUNKWHALE_PLUGINS`` environment variable, like this::
FUNKWHALE_PLUGINS=myplugin,anotherplugin
We provide a command to make it easy to install third-party plugins::
python manage.py fw plugins install https://pluginurl.zip
.. note::
If you use the command, you will still need to append the plugin name to ``FUNKWHALE_PLUGINS``
Types of plugins
----------------
There are two types of plugins:
1. Plugins that are accessible to end-users, a.k.a. user-level plugins. This is the case of our Scrobbler plugin
2. Pod-level plugins that are configured by pod admins and are not tied to a particular user
Additionally, user-level plugins can be regular plugins or source plugins. A source plugin provides
a way to import files from a third-party service, e.g via webdav, FTP or something similar.
Hooks and filters
-----------------
Funkwhale includes two kind of entrypoints for plugins to use: hooks and filters. B
Hooks should be used when you want to react to some change. For instance, the ``LISTENING_CREATED`` hook
notify each registered callback that a listening was created. Our ``scrobbler`` plugin has a callback
registered to this hook, so that it can notify Last.fm properly:
.. code-block:: python
from config import plugins
from .funkwhale_startup import PLUGIN
@plugins.register_hook(plugins.LISTENING_CREATED, PLUGIN)
def notify_lastfm(listening, conf, **kwargs):
# do something
Filters work slightly differently, and expect callbacks to return a value that will be used by Funkwhale.
For instance, the ``PLUGINS_DEPENDENCIES`` filter can be used as a way to install additional dependencies needed by your plugin:
.. code-block:: python
# funkwhale_startup.py
# ...
from config import plugins
@plugins.register_filter(plugins.PLUGINS_DEPENDENCIES, PLUGIN)
def dependencies(dependencies, **kwargs):
return dependencies + ["django_prometheus"]
To sum it up, hooks are used when you need to react to something, and filters when you need to alter something.
Writing a plugin
----------------
Regardless of the type of plugin you want to write, lots of concepts are similar.
First, a plugin need three files:
- a ``__init__.py`` file, since it's a Python package
- a ``funkwhale_startup.py`` file, that is loaded during Funkwhale initialization
- a ``funkwhale_ready.py`` file, that is loaded when Funkwhale is configured and ready
So your plugin directory should look like this::
myplugin
├── funkwhale_ready.py
├── funkwhale_startup.py
└── __init__.py
Now, let's write our plugin!
``funkwhale_startup.py`` is where you declare your plugin and it's configuration options:
.. code-block:: python
# funkwhale_startup.py
from config import plugins
PLUGIN = plugins.get_plugin_config(
name="myplugin",
label="My Plugin",
description="An example plugin that greets you",
version="0.1",
# here, we write a user-level plugin
user=True,
conf=[
# this configuration options are editable by each user
{"name": "greeting", "type": "text", "label": "Greeting", "default": "Hello"},
],
)
Now that our plugin is declared and configured, let's implement actual functionality in ``funkwhale_ready.py``:
.. code-block:: python
# funkwhale_ready.py
from django.urls import path
from rest_framework import response
from rest_framework import views
from config import plugins
from .funkwhale_startup import PLUGIN
# Our greeting view, where the magic happens
class GreetingView(views.APIView):
permission_classes = []
def get(self, request, *args, **kwargs):
# retrieve plugin configuration for the current user
conf = plugins.get_conf(PLUGIN["name"], request.user)
if not conf["enabled"]:
# plugin is disabled for this user
return response.Response(status=405)
greeting = conf["conf"]["greeting"]
data = {
"greeting": "{} {}!".format(greeting, request.user.username)
}
return response.Response(data)
# Ensure our view is known by Django and available at /greeting
@plugins.register_filter(plugins.URLS, PLUGIN)
def register_view(urls, **kwargs):
return urls + [
path('greeting', GreetingView.as_view())
]
And that's pretty much it. Now, login, visit https://yourpod.domain/settings/plugins, set a value in the ``greeting`` field and enable the plugin.
After that, you should be greeted properly if you go to https://yourpod.domain/greeting.
Hooks reference
---------------
.. autodata:: config.plugins.LISTENING_CREATED
Filters reference
-----------------
.. autodata:: config.plugins.PLUGINS_DEPENDENCIES
.. autodata:: config.plugins.PLUGINS_APPS
.. autodata:: config.plugins.PLUGINSMIDDLEWARES_BEFORE_DEPENDENCIES
.. autodata:: config.plugins.MIDDLEWARES_AFTER
.. autodata:: config.plugins.URLS

View File

@ -79,6 +79,7 @@ GLOBAL_REPLACES = [
("#ff4335", "var(--danger-focus-color)"), ("#ff4335", "var(--danger-focus-color)"),
] ]
def discard_unused_icons(rule): def discard_unused_icons(rule):
""" """
Add an icon to this list if you want to use it in the app. Add an icon to this list if you want to use it in the app.
@ -890,7 +891,9 @@ def replace_vars(source, dest):
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Replace hardcoded values by CSS vars and strip unused rules") parser = argparse.ArgumentParser(
description="Replace hardcoded values by CSS vars and strip unused rules"
)
parser.add_argument( parser.add_argument(
"source", help="Source path of the fomantic-ui-less distribution to fix" "source", help="Source path of the fomantic-ui-less distribution to fix"
) )

View File

@ -0,0 +1,113 @@
<template>
<form :class="['ui form', {loading: isLoading}]" @submit.prevent="submit">
<h3>{{ plugin.label }}</h3>
<div v-if="plugin.description" v-html="markdown.makeHtml(plugin.description)"></div>
<div class="ui clearing hidden divider"></div>
<div v-if="errors.length > 0" role="alert" class="ui negative message">
<div class="header"><translate translate-context="Content/*/Error message.Title">Error while saving plugin</translate></div>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
</ul>
</div>
<div class="field">
<div class="ui toggle checkbox">
<input :id="`${plugin.name}-enabled`" type="checkbox" v-model="enabled" />
<label :for="`${plugin.name}-enabled`"><translate translate-context="*/*/*">Enabled</translate></label>
</div>
</div>
<div class="ui clearing hidden divider"></div>
<div v-if="plugin.source" class="field">
<label for="plugin-library"><translate translate-context="*/*/*/Noun">Library</translate></label>
<select id="plugin-library" v-model="values['library']">
<option :value="l.uuid" v-for="l in libraries" :key="l.uuid">{{ l.name }}</option>
</select>
<div>
<translate translate-context="*/*/Paragraph/Noun">Library where files should be imported.</translate>
</div>
</div>
<template v-if="plugin.conf && plugin.conf.length > 0" v-for="field in plugin.conf">
<div v-if="field.type === 'text'" class="field">
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
<input :id="`plugin-${field.name}`" type="text" v-model="values[field.name]">
<div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
</div>
<div v-if="field.type === 'long_text'" class="field">
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
<textarea :id="`plugin-${field.name}`" type="text" v-model="values[field.name]" rows="5" />
<div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
</div>
<div v-if="field.type === 'url'" class="field">
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
<input :id="`plugin-${field.name}`" type="url" v-model="values[field.name]">
<div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
</div>
</div>
<div v-if="field.type === 'password'" class="field">
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
<input :id="`plugin-${field.name}`" type="password" v-model="values[field.name]">
<div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
</div>
</template>
<button
type="submit"
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']">
<translate translate-context="Content/*/Button.Label/Verb">Save</translate>
</button>
<button
type="scan"
v-if="plugin.source"
@click.prevent="submitAndScan"
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']">
<translate translate-context="Content/*/Button.Label/Verb">Scan</translate>
</button>
<div class="ui clearing hidden divider"></div>
</form>
</template>
<script>
import axios from "axios"
import lodash from '@/lodash'
import showdown from 'showdown'
export default {
props: ['plugin', "libraries"],
data () {
return {
markdown: new showdown.Converter(),
isLoading: false,
enabled: this.plugin.enabled,
values: lodash.clone(this.plugin.values || {}),
errors: [],
}
},
methods: {
async submit () {
this.isLoading = true
this.errors = []
let url = `plugins/${this.plugin.name}`
let enableUrl = this.enabled ? `${url}/enable` : `${url}/enable`
await axios.post(enableUrl)
try {
await axios.post(url, this.values)
} catch (e) {
this.errors = e.backendErrors
}
this.isLoading = false
},
async scan () {
this.isLoading = true
this.errors = []
let url = `plugins/${this.plugin.name}/scan`
try {
await axios.post(url, this.values)
} catch (e) {
this.errors = e.backendErrors
}
this.isLoading = false
},
async submitAndScan () {
await this.submit()
await this.scan()
}
},
}
</script>

View File

@ -257,6 +257,20 @@
</translate> </translate>
</empty-state> </empty-state>
</section> </section>
<section class="ui text container" id="plugins">
<div class="ui hidden divider"></div>
<h2 class="ui header">
<i class="code icon"></i>
<div class="content">
<translate translate-context="Content/Settings/Title/Noun">Plugins</translate>
</div>
</h2>
<p><translate translate-context="Content/Settings/Paragraph">Use plugins to extend Funkwhale and get additional features.</translate></p>
<router-link class="ui basic success button" :to="{name: 'settings.plugins'}">
<translate translate-context="Content/Settings/Button.Label">Manage plugins</translate>
</router-link>
</section>
<section class="ui text container"> <section class="ui text container">
<div class="ui hidden divider"></div> <div class="ui hidden divider"></div>
<h2 class="ui header"> <h2 class="ui header">

View File

@ -154,6 +154,14 @@ export default new Router({
/* webpackChunkName: "settings" */ "@/components/auth/ApplicationNew" /* webpackChunkName: "settings" */ "@/components/auth/ApplicationNew"
) )
}, },
{
path: "/settings/plugins",
name: "settings.plugins",
component: () =>
import(
/* webpackChunkName: "settings" */ "@/views/auth/Plugins"
)
},
{ {
path: "/settings/applications/:id/edit", path: "/settings/applications/:id/edit",
name: "settings.applications.edit", name: "settings.applications.edit",

View File

@ -0,0 +1,59 @@
<template>
<main class="main pusher" v-title="labels.title">
<section class="ui vertical stripe segment">
<div class="ui small text container">
<h2>{{ labels.title }}</h2>
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
</div>
<plugin-form
v-if="plugins && plugins.length > 0"
v-for="plugin in plugins"
:plugin="plugin"
:libraries="libraries"
:key="plugin.name"></plugin-form>
<empty-state v-else></empty-state>
</div>
</section>
</main>
</template>
<script>
import axios from 'axios'
import PluginForm from '@/components/auth/Plugin'
export default {
components: {
PluginForm
},
data () {
return {
isLoading: true,
plugins: null,
libraries: null,
}
},
async created () {
await this.fetchData()
},
computed: {
labels() {
let title = this.$pgettext('Head/Login/Title', "Manage plugins")
return {
title
}
}
},
methods: {
async fetchData () {
this.isLoading = true
let response = await axios.get('plugins')
this.plugins = response.data
response = await axios.get('libraries', {paramis: {scope: 'me', page_size: 50}})
this.libraries = response.data.results
this.isLoading = false
}
}
}
</script>