Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • funkwhale/funkwhale
  • Luclu7/funkwhale
  • mbothorel/funkwhale
  • EorlBruder/funkwhale
  • tcit/funkwhale
  • JocelynDelalande/funkwhale
  • eneiluj/funkwhale
  • reg/funkwhale
  • ButterflyOfFire/funkwhale
  • m4sk1n/funkwhale
  • wxcafe/funkwhale
  • andybalaam/funkwhale
  • jcgruenhage/funkwhale
  • pblayo/funkwhale
  • joshuaboniface/funkwhale
  • n3ddy/funkwhale
  • gegeweb/funkwhale
  • tohojo/funkwhale
  • emillumine/funkwhale
  • Te-k/funkwhale
  • asaintgenis/funkwhale
  • anoadragon453/funkwhale
  • Sakada/funkwhale
  • ilianaw/funkwhale
  • l4p1n/funkwhale
  • pnizet/funkwhale
  • dante383/funkwhale
  • interfect/funkwhale
  • akhardya/funkwhale
  • svfusion/funkwhale
  • noplanman/funkwhale
  • nykopol/funkwhale
  • roipoussiere/funkwhale
  • Von/funkwhale
  • aurieh/funkwhale
  • icaria36/funkwhale
  • floreal/funkwhale
  • paulwalko/funkwhale
  • comradekingu/funkwhale
  • FurryJulie/funkwhale
  • Legolars99/funkwhale
  • Vierkantor/funkwhale
  • zachhats/funkwhale
  • heyjake/funkwhale
  • sn0w/funkwhale
  • jvoisin/funkwhale
  • gordon/funkwhale
  • Alexander/funkwhale
  • bignose/funkwhale
  • qasim.ali/funkwhale
  • fakegit/funkwhale
  • Kxze/funkwhale
  • stenstad/funkwhale
  • creak/funkwhale
  • Kaze/funkwhale
  • Tixie/funkwhale
  • IISergII/funkwhale
  • lfuelling/funkwhale
  • nhaddag/funkwhale
  • yoasif/funkwhale
  • ifischer/funkwhale
  • keslerm/funkwhale
  • flupe/funkwhale
  • petitminion/funkwhale
  • ariasuni/funkwhale
  • ollie/funkwhale
  • ngaumont/funkwhale
  • techknowlogick/funkwhale
  • Shleeble/funkwhale
  • theflyingfrog/funkwhale
  • jonatron/funkwhale
  • neobrain/funkwhale
  • eorn/funkwhale
  • KokaKiwi/funkwhale
  • u1-liquid/funkwhale
  • marzzzello/funkwhale
  • sirenwatcher/funkwhale
  • newer027/funkwhale
  • codl/funkwhale
  • Zwordi/funkwhale
  • gisforgabriel/funkwhale
  • iuriatan/funkwhale
  • simon/funkwhale
  • bheesham/funkwhale
  • zeoses/funkwhale
  • accraze/funkwhale
  • meliurwen/funkwhale
  • divadsn/funkwhale
  • Etua/funkwhale
  • sdrik/funkwhale
  • Soran/funkwhale
  • kuba-orlik/funkwhale
  • cristianvogel/funkwhale
  • Forceu/funkwhale
  • jeff/funkwhale
  • der_scheibenhacker/funkwhale
  • owlnical/funkwhale
  • jovuit/funkwhale
  • SilverFox15/funkwhale
  • phw/funkwhale
  • mayhem/funkwhale
  • sridhar/funkwhale
  • stromlin/funkwhale
  • rrrnld/funkwhale
  • nitaibezerra/funkwhale
  • jaller94/funkwhale
  • pcouy/funkwhale
  • eduxstad/funkwhale
  • codingHahn/funkwhale
  • captain/funkwhale
  • polyedre/funkwhale
  • leishenailong/funkwhale
  • ccritter/funkwhale
  • lnceballosz/funkwhale
  • fpiesche/funkwhale
  • Fanyx/funkwhale
  • markusblogde/funkwhale
  • Firobe/funkwhale
  • devilcius/funkwhale
  • freaktechnik/funkwhale
  • blopware/funkwhale
  • cone/funkwhale
  • thanksd/funkwhale
  • vachan-maker/funkwhale
  • bbenti/funkwhale
  • tarator/funkwhale
  • prplecake/funkwhale
  • DMarzal/funkwhale
  • lullis/funkwhale
  • hanacgr/funkwhale
  • albjeremias/funkwhale
  • xeruf/funkwhale
  • llelite/funkwhale
  • RoiArthurB/funkwhale
  • cloo/funkwhale
  • nztvar/funkwhale
  • Keunes/funkwhale
  • petitminion/funkwhale-petitminion
  • m-idler/funkwhale
  • SkyLeite/funkwhale
140 results
Show changes
Commits on Source (10)
Showing
with 590 additions and 14 deletions
......@@ -81,10 +81,13 @@ else:
logger.info("Loaded env file at %s/.env", path)
break
FUNKWHALE_PLUGINS_PATH = env(
"FUNKWHALE_PLUGINS_PATH", default="/srv/funkwhale/plugins/"
FUNKWHALE_PLUGINS_PATH = env.list(
"FUNKWHALE_PLUGINS_PATH",
default=["/srv/funkwhale/plugins/", str(ROOT_DIR.path("plugins"))],
)
sys.path.append(FUNKWHALE_PLUGINS_PATH)
for path in FUNKWHALE_PLUGINS_PATH:
sys.path.append(path)
FUNKWHALE_HOSTNAME = None
FUNKWHALE_HOSTNAME_SUFFIX = env("FUNKWHALE_HOSTNAME_SUFFIX", default=None)
......@@ -186,6 +189,7 @@ if RAVEN_ENABLED:
# Apps specific for this project go here.
LOCAL_APPS = (
"funkwhale_api.common.apps.CommonConfig",
"funkwhale_api.plugins",
"funkwhale_api.activity.apps.ActivityConfig",
"funkwhale_api.users", # custom users app
"funkwhale_api.users.oauth",
......@@ -225,6 +229,7 @@ MIDDLEWARE = (
"django.middleware.security.SecurityMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware",
"funkwhale_api.plugins.middleware.AttachPluginsConfMiddleware",
"funkwhale_api.common.middleware.SPAFallbackMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
......@@ -612,12 +617,12 @@ REST_FRAMEWORK = {
"funkwhale_api.federation.parsers.ActivityParser",
),
"DEFAULT_AUTHENTICATION_CLASSES": (
"oauth2_provider.contrib.rest_framework.OAuth2Authentication",
"funkwhale_api.common.authentication.OAuth2Authentication",
"funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS",
"funkwhale_api.common.authentication.BearerTokenHeaderAuth",
"funkwhale_api.common.authentication.JSONWebTokenAuthentication",
"rest_framework.authentication.BasicAuthentication",
"rest_framework.authentication.SessionAuthentication",
"funkwhale_api.common.authentication.BasicAuthentication",
"funkwhale_api.common.authentication.SessionAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": (
"funkwhale_api.users.oauth.permissions.ScopePermission",
......@@ -888,3 +893,5 @@ MODERATION_EMAIL_NOTIFICATIONS_ENABLED = env.bool(
# Delay in days after signup before we show the "support us" messages
INSTANCE_SUPPORT_MESSAGE_DELAY = env.int("INSTANCE_SUPPORT_MESSAGE_DELAY", default=15)
FUNKWHALE_SUPPORT_MESSAGE_DELAY = env.int("FUNKWHALE_SUPPORT_MESSAGE_DELAY", default=15)
PLUGINS_FAIL_LOUDLY = env.bool("PLUGINS_FAIL_LOUDLY", default=False)
from django.utils.encoding import smart_text
from django.utils.translation import ugettext as _
from oauth2_provider.contrib.rest_framework import (
OAuth2Authentication as BaseOAuth2Authentication,
)
from rest_framework import exceptions
from rest_framework_jwt import authentication
from rest_framework_jwt.settings import api_settings
from rest_framework.authentication import BasicAuthentication as BaseBasicAuthentication
from rest_framework.authentication import (
SessionAuthentication as BaseSessionAuthentication,
)
class JSONWebTokenAuthenticationQS(authentication.BaseJSONWebTokenAuthentication):
from funkwhale_api.plugins import authentication as plugin_authentication
class JSONWebTokenAuthenticationQS(
plugin_authentication.AttachPluginsConfMixin,
authentication.BaseJSONWebTokenAuthentication,
):
www_authenticate_realm = "api"
......@@ -22,7 +35,10 @@ class JSONWebTokenAuthenticationQS(authentication.BaseJSONWebTokenAuthentication
)
class BearerTokenHeaderAuth(authentication.BaseJSONWebTokenAuthentication):
class BearerTokenHeaderAuth(
plugin_authentication.AttachPluginsConfMixin,
authentication.BaseJSONWebTokenAuthentication,
):
"""
For backward compatibility purpose, we used Authorization: JWT <token>
but Authorization: Bearer <token> is probably better.
......@@ -65,7 +81,10 @@ class BearerTokenHeaderAuth(authentication.BaseJSONWebTokenAuthentication):
return auth
class JSONWebTokenAuthentication(authentication.JSONWebTokenAuthentication):
class JSONWebTokenAuthentication(
plugin_authentication.AttachPluginsConfMixin,
authentication.JSONWebTokenAuthentication,
):
def authenticate(self, request):
auth = super().authenticate(request)
......@@ -73,3 +92,21 @@ class JSONWebTokenAuthentication(authentication.JSONWebTokenAuthentication):
if not auth[0].actor:
auth[0].create_actor()
return auth
class OAuth2Authentication(
plugin_authentication.AttachPluginsConfMixin, BaseOAuth2Authentication
):
pass
class BasicAuthentication(
plugin_authentication.AttachPluginsConfMixin, BaseBasicAuthentication
):
pass
class SessionAuthentication(
plugin_authentication.AttachPluginsConfMixin, BaseSessionAuthentication
):
pass
......@@ -3,6 +3,8 @@ from django.utils import timezone
from funkwhale_api.music.models import Track
from . import signals
class Listening(models.Model):
creation_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
......
from rest_framework import serializers
from funkwhale_api import plugins
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.common import utils
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music import models as music_models
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
......@@ -53,4 +56,18 @@ class ListeningWriteSerializer(serializers.ModelSerializer):
def create(self, validated_data):
validated_data["user"] = self.context["user"]
return super().create(validated_data)
instance = super().create(validated_data)
plugins_conf = getattr(self.context["request"], "plugins_conf", None)
utils.on_commit(
plugins.hooks.dispatch,
"history.listening.created",
listening=instance,
plugins_conf=plugins_conf,
)
return instance
class NowSerializer(serializers.Serializer):
track = serializers.PrimaryKeyRelatedField(
queryset=music_models.Track.objects.all()
)
from funkwhale_api import plugins
plugins.hooks.register(
plugins.Hook("history.listening.created", providing_args=["listening"])
)
plugins.hooks.register(
plugins.Hook("history.listening.now", providing_args=["track", "user"])
)
from rest_framework import mixins, viewsets
from rest_framework import mixins, viewsets, response
from rest_framework.decorators import action
from django.db.models import Prefetch
from funkwhale_api import plugins
from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions
from funkwhale_api.common import fields, permissions, utils
from funkwhale_api.music.models import Track
from funkwhale_api.music import utils as music_utils
from . import filters, models, serializers
......@@ -54,3 +56,16 @@ class ListeningViewSet(
context = super().get_serializer_context()
context["user"] = self.request.user
return context
@action(methods=["post"], detail=False)
def now(self, request, *args, **kwargs):
serializer = serializers.NowSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
utils.on_commit(
plugins.hooks.dispatch,
"history.listening.now",
user=request.user,
track=serializer.validated_data["track"],
plugins_conf=request.plugins_conf,
)
return response.Response({}, status=204)
import persisting_theory
import django.dispatch
from django import apps
import logging
from funkwhale_api.common import session
from . import config
logger = logging.getLogger(__name__)
class Plugin(apps.AppConfig):
_is_funkwhale_plugin = True
version = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.hooks = HookRegistry()
self.settings = SettingRegistry()
self.user_settings = SettingRegistry()
self.logger = None
def ready(self):
super().ready()
logging.info("Loading plugin %s…", self.label)
self.logger = logging.getLogger("funkwhale_api.plugin.{}".format(self.name))
self.load()
logging.info("Plugin %s loaded", self.label)
def load(self):
pass
def get_requests_session(self):
return session.get_session()
class FuncRegistry(persisting_theory.Registry):
def connect(self, hook_name):
def inner(handler):
self[hook_name] = handler
return handler
return inner
class HookRegistry(FuncRegistry):
pass
class SettingRegistry(persisting_theory.Registry):
def prepare_name(self, data, name):
return data().identifier()
class PluginException(Exception):
pass
class PluginNotFound(PluginException):
pass
class Skip(PluginException):
pass
class PluginSignal(object):
def __init__(self, name, providing_args=[]):
self.name = name
self.providing_args = providing_args
class Hook(PluginSignal):
pass
class SignalsRegistry(persisting_theory.Registry):
def prepare_name(self, data, name):
return data.name
def dispatch(self, hook_name, plugins_conf, **kwargs):
"""
Call all handlers connected to hook_name in turn.
"""
from django.conf import settings
if not plugins_conf:
logger.debug("[Plugin:hook:dispatch] No plugin configured")
return
if hook_name not in self:
raise LookupError(hook_name)
logger.debug("[Plugin:hook:%s] Dispatching hook", hook_name)
matching_hooks = []
for row in plugins_conf:
try:
matching_hooks.append((row, row["obj"].hooks[hook_name]))
except KeyError:
continue
if matching_hooks:
logger.debug(
"[Plugin:hook:%s] %s handlers found", hook_name, len(matching_hooks)
)
else:
logger.debug("[Plugin:hook:%s] No handler founds", hook_name)
return
for row, handler in matching_hooks:
logger.debug(
"[Plugin:hook:%s] Calling handler %s from plugin %s",
hook_name,
handler,
row["obj"].name,
)
try:
handler(plugin_conf=row, **kwargs)
except Skip:
logger.debug("[Plugin:hook:%s] handler skipped", hook_name)
except Exception as e:
logger.exception(
"[Plugin:hook:%s] unknown exception with handler %s",
hook_name,
handler,
)
if settings.PLUGINS_FAIL_LOUDLY:
raise e
else:
logger.debug(
"[Plugin:hook:%s] handler %s called successfully",
hook_name,
handler,
)
logger.debug("[Plugin:hook:%s] Done", hook_name)
hooks = SignalsRegistry()
def get_plugin(name):
try:
plugin = apps.apps.get_app_config(name)
except LookupError:
raise PluginNotFound(name)
if not getattr(plugin, "_is_funkwhale_plugin", False):
raise PluginNotFound(name)
return plugin
def get_all_plugins():
return [
app
for app in apps.apps.get_app_configs()
if getattr(app, "_is_funkwhale_plugin", False)
]
def generate_plugins_conf(plugins, user=None):
from . import models
plugins_conf = []
qs = models.Plugin.objects.filter(is_enabled=True).values("name", "config")
by_plugin_name = {obj["name"]: obj["config"] for obj in qs}
for plugin in plugins:
if plugin.name not in by_plugin_name:
continue
conf = {
"obj": plugin,
"user": None,
"settings": by_plugin_name[plugin.name] or {},
}
plugins_conf.append(conf)
return plugins_conf
def update_plugins_conf_with_user_settings(plugins_conf, user):
if not plugins_conf:
return
from . import models
if plugins_conf and user and user.is_authenticated:
qs = models.UserPlugin.objects.filter(
user=user, plugin__is_enabled=True, is_enabled=True
).values("plugin__name", "config")
by_plugin_name = {obj["plugin__name"]: obj["config"] for obj in qs}
for row in plugins_conf:
if row["obj"].name in by_plugin_name:
row["user"] = {
"id": user.pk,
"settings": by_plugin_name[row["obj"].name],
}
return plugins_conf
def attach_plugins_conf(obj):
from funkwhale_api.common import preferences
plugins_enabled = preferences.get("plugins__enabled")
if plugins_enabled:
conf = generate_plugins_conf(plugins=get_all_plugins())
else:
conf = None
setattr(obj, "plugins_conf", conf)
from funkwhale_api.common import admin
from . import models
@admin.register(models.Plugin)
class PluginAdmin(admin.ModelAdmin):
list_display = ["name", "creation_date", "is_enabled"]
list_filter = ["is_enabled"]
list_select_related = True
@admin.register(models.UserPlugin)
class UserPluginAdmin(admin.ModelAdmin):
list_display = ["plugin", "user", "creation_date", "is_enabled"]
search_fields = ["user__username", "plugin__name"]
list_filter = ["plugin__name", "is_enabled"]
list_select_related = True
from funkwhale_api import plugins
class AttachPluginsConfMixin(object):
def authenticate(self, request):
auth = super().authenticate(request)
self.update_plugins_conf(request, auth)
return auth
def update_plugins_conf(self, request, auth):
if auth:
plugins.update_plugins_conf_with_user_settings(
getattr(request, "plugins_conf", []), user=auth[0]
)
from django import forms
from dynamic_preferences import types
SettingSection = types.Section
StringSetting = types.StringPreference
class PasswordSetting(types.StringPreference):
widget = forms.PasswordInput
class BooleanSetting(types.BooleanPreference):
# Boolean are supported in JSON, so no need to serialized to a string
serializer = None
class IntSetting(types.IntegerPreference):
# Integers are supported in JSON, so no need to serialized to a string
serializer = None
def validate_config(payload, settings):
"""
Dynamic preferences stores settings in a separate database table. However
it is a bit too much for our use cases, and we simply want to store
these in a JSONField on the corresponding model row.
This validates the payload using the dynamic preferences serializers
and return a config that is ready to be persisted as JSON
"""
final = {}
for klass in settings:
setting = klass()
setting_id = setting.identifier()
try:
value = payload[setting_id]
except KeyError:
continue
setting.validate(value)
if setting.serializer:
value = setting.serializer.serialize(value)
final[setting_id] = value
return final
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
plugins = types.Section("plugins")
@global_preferences_registry.register
class PluginsEnabled(types.BooleanPreference):
section = plugins
show_in_api = True
name = "enabled"
default = True
verbose_name = "Enable Funkwhale plugins"
help_text = "If disabled, all installed and enabled plugins will be ignored."
import factory
from funkwhale_api.factories import registry, NoUpdateOnCreate
from funkwhale_api.users.factories import UserFactory
@registry.register
class PluginFactory(factory.django.DjangoModelFactory):
is_enabled = True
config = factory.Faker("pydict", nb_elements=3)
class Meta:
model = "plugins.Plugin"
@factory.post_generation
def refresh(self, created, *args, **kwargs):
"""
Needed to ensure we have JSON serialized value in the config field
"""
if created:
self.refresh_from_db()
@registry.register
class UserPluginFactory(factory.django.DjangoModelFactory):
is_enabled = True
user = factory.SubFactory(UserFactory)
plugin = factory.SubFactory(PluginFactory)
config = factory.Faker("pydict", nb_elements=3)
class Meta:
model = "plugins.UserPlugin"
@factory.post_generation
def refresh(self, created, *args, **kwargs):
"""
Needed to ensure we have JSON serialized value in the config field
"""
if created:
self.refresh_from_db()
from funkwhale_api import plugins
class AttachPluginsConfMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
plugins.attach_plugins_conf(request)
return self.get_response(request)
# Generated by Django 2.2.4 on 2019-09-23 15:17
from django.conf import settings
import django.contrib.postgres.fields.jsonb
import django.core.serializers.json
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Plugin',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=70, unique=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('is_enabled', models.BooleanField()),
('config', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=None, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000, null=True)),
],
),
migrations.CreateModel(
name='UserPlugin',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('is_enabled', models.BooleanField()),
('config', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=None, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000, null=True)),
('plugin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_plugins', to='plugins.Plugin')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_plugins', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'plugin')},
},
),
]
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.postgres.fields import JSONField
from django.db import models
from django.utils import timezone
class Plugin(models.Model):
name = models.CharField(unique=True, max_length=70)
creation_date = models.DateTimeField(default=timezone.now)
is_enabled = models.BooleanField()
config = JSONField(
default=None, max_length=50000, encoder=DjangoJSONEncoder, blank=True, null=True
)
def __str__(self):
return self.name
class UserPlugin(models.Model):
plugin = models.ForeignKey(
Plugin, related_name="user_plugins", on_delete=models.CASCADE
)
user = models.ForeignKey(
"users.User", related_name="user_plugins", on_delete=models.CASCADE
)
creation_date = models.DateTimeField(default=timezone.now)
is_enabled = models.BooleanField()
config = JSONField(
default=None, max_length=50000, encoder=DjangoJSONEncoder, blank=True, null=True
)
class Meta:
unique_together = ("user", "plugin")
......@@ -5,6 +5,8 @@ from rest_framework import authentication, exceptions
from funkwhale_api.users.models import User
from funkwhale_api.plugins import authentication as plugin_authentication
def get_token(salt, password):
to_hash = password + salt
......@@ -41,8 +43,15 @@ def authenticate_salt(username, salt, token):
return (user, None)
class SubsonicAuthentication(authentication.BaseAuthentication):
class SubsonicAuthentication(
plugin_authentication.AttachPluginsConfMixin, authentication.BaseAuthentication
):
def authenticate(self, request):
auth = self.perform_authentication(request)
self.update_plugins_conf(request, auth)
return auth
def perform_authentication(self, request):
data = request.GET or request.POST
username = data.get("u")
if not username:
......@@ -56,5 +65,4 @@ class SubsonicAuthentication(authentication.BaseAuthentication):
if p:
return authenticate(username, p)
return authenticate_salt(username, s, t)
from funkwhale_api import plugins
class Plugin(plugins.Plugin):
name = "fw_scrobbler"
help = "A simple plugin that enables scrobbling to ListenBrainz and Last.fm"
version = "0.1"
def load(self):
from . import config
from . import hooks
from funkwhale_api import plugins
plugin = plugins.get_plugin("fw_scrobbler")
service = plugins.config.SettingSection("service", "Scrobbling Service")
@plugin.user_settings.register
class URL(plugins.config.StringSetting):
section = service
name = "url"
default = ""
verbose_name = "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/",
"- ListenBrainz: http://proxy.listenbrainz.org/",
"- Libre.fm: http://turtle.libre.fm/",
)
@plugin.user_settings.register
class Username(plugins.config.StringSetting):
section = service
name = "username"
default = ""
verbose_name = "Your scrobbler username"
@plugin.user_settings.register
class Password(plugins.config.PasswordSetting):
section = service
name = "password"
default = ""
verbose_name = "Your scrobbler password"