Verified Commit 7b18e46f authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch 'release/0.13'

parents 107cca7b d299964c
......@@ -9,3 +9,5 @@ FUNKWHALE_PROTOCOL=http
......@@ -10,6 +10,127 @@ This changelog is viewable on the web at
.. towncrier
0.13 (2018-05-19)
Upgrade instructions are available at
- Can now import and play flac files (#157)
- Simpler permission system (#152)
- Store file length, size and bitrate (#195)
- We now have a brand new instance settings interface in the front-end (#206)
- Disabled browsable HTML API in production (#205)
- Instances can now indicate on the nodeinfo endpoint if they want to remain
private (#200)
- .well-known/nodeinfo endpoint can now answer to request with Accept:
application/json (#197)
- Fixed escaping issue of track name in playlist modal (#201)
- Fixed missing dot when downloading file (#204)
- In-place imported tracks with non-ascii characters don't break reverse-proxy
serving (#196)
- Removed Python 3.6 dependency (secrets module) (#198)
- Uplayable tracks are now properly disabled in the interface (#199)
Instance settings interface
Prior to this release, the only way to update instance settings (such as
instance description, signup policy, federation configuration, etc.) was using
the admin interface provided by Django (the back-end framework which power the API).
This interface worked, but was not really-user friendly and intuitive.
Starting from this release, we now offer a dedicated interface directly
in the front-end. You can view and edit all your instance settings from here,
assuming you have the required permissions.
This interface is available at ``/manage/settings` and via link in the sidebar.
Storage of bitrate, size and length in database
Starting with this release, when importing files, Funkwhale will store
additional information about audio files:
- Bitrate
- Size (in bytes)
- Duration
This change is not retroactive, meaning already imported files will lack those
informations. The interface and API should work as before in such case, however,
we offer a command to deal with legacy files and populate the missing values.
On docker setups:
.. code-block:: shell
docker-compose run --rm api python fix_track_files
On non-docker setups:
.. code-block:: shell
# from your activated virtualenv
python fix_track_files
.. note::
The execution time for this command is proportional to the number of
audio files stored on your instance. This is because we need to read the
files from disk to fetch the data. You can run it in the background
while Funkwhale is up.
It's also safe to interrupt this command and rerun it at a later point, or run
it multiple times.
Use the --dry-run flag to check how many files would be impacted.
Simpler permission system
Starting from this release, the permission system is much simpler. Up until now,
we were using Django's built-in permission system, which was working, but also
quite complex to deal with.
The new implementation relies on simpler logic, which will make integration
on the front-end in upcoming releases faster and easier.
If you have manually given permissions to users on your instance,
you can migrate those to the new system.
On docker setups:
.. code-block:: shell
docker-compose run --rm api python script django_permissions_to_user_permissions --no-input
On non-docker setups:
.. code-block:: shell
# in your virtualenv
python api/ script django_permissions_to_user_permissions --no-input
There is still no dedicated interface to manage user permissions, but you
can use the admin interface at ``/api/admin/users/user/`` for that purpose in
the meantime.
0.12 (2018-05-09)
......@@ -110,9 +231,7 @@ We offer two settings to manage nodeinfo in your Funkwhale instance:
and user activity.
To make your instance fully compatible with the nodeinfo protocol, you need to
to edit your nginx configuration file:
.. code-block::
to edit your nginx configuration file::
# before
......@@ -130,9 +249,7 @@ to edit your nginx configuration file:
You can do the same if you use apache:
.. code-block::
You can do the same if you use apache::
# before
......@@ -406,8 +406,18 @@ REST_FRAMEWORK = {
'PASSWORD_RESET_SERIALIZER': 'funkwhale_api.users.serializers.PasswordResetSerializer' # noqa
# -*- coding: utf-8 -*-
__version__ = '0.12'
__version__ = '0.13'
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
......@@ -16,5 +16,5 @@ class APIAutenticationRequired(
help_text = (
'If disabled, anonymous users will be able to query the API'
'and access music data (as well as other data exposed in the API '
'without specific permissions)'
'without specific permissions).'
from import BaseCommand, CommandError
from funkwhale_api.common import scripts
class Command(BaseCommand):
help = 'Run a specific script from funkwhale_api/common/scripts/'
def add_arguments(self, parser):
parser.add_argument('script_name', nargs='?', type=str)
'--noinput', '--no-input', action='store_false', dest='interactive',
help="Do NOT prompt the user for input of any kind.",
def handle(self, *args, **options):
name = options['script_name']
if not name:
available_scripts = self.get_scripts()
script = available_scripts[name]
except KeyError:
raise CommandError(
'{} is not a valid script. Run python script for a '
'list of available scripts'.format(name))
if options['interactive']:
message = (
'Are you sure you want to execute the script {}?\n\n'
"Type 'yes' to continue, or 'no' to cancel: "
if input(''.join(message)) != 'yes':
raise CommandError("Script cancelled.")
script['entrypoint'](self, **options)
def show_help(self):
indentation = 4
self.stdout.write('Available scripts:')
self.stdout.write('Launch with: python <script_name>')
available_scripts = self.get_scripts()
for name, script in sorted(available_scripts.items()):
for line in script['help'].splitlines():
self.stdout.write('     {}'.format(line))
def get_scripts(self):
available_scripts = [
k for k in sorted(scripts.__dict__.keys())
if not k.startswith('__')
data = {}
for name in available_scripts:
module = getattr(scripts, name)
data[name] = {
'name': name,
'help': module.__doc__.strip(),
'entrypoint': module.main
return data
......@@ -3,7 +3,7 @@ import operator
from django.conf import settings
from django.http import Http404
from rest_framework.permissions import BasePermission, DjangoModelPermissions
from rest_framework.permissions import BasePermission
from funkwhale_api.common import preferences
......@@ -16,17 +16,6 @@ class ConditionalAuthentication(BasePermission):
return True
class HasModelPermission(DjangoModelPermissions):
Same as DjangoModelPermissions, but we pin the model:
class MyModelPermission(HasModelPermission):
model = User
def get_required_permissions(self, method, model_cls):
return super().get_required_permissions(method, self.model)
class OwnerPermission(BasePermission):
Ensure the request user is the owner of the object.
from . import django_permissions_to_user_permissions
from . import test
Convert django permissions to user permissions in the database,
following the work done in #152.
from django.db.models import Q
from funkwhale_api.users import models
from django.contrib.auth.models import Permission
mapping = {
'dynamic_preferences.change_globalpreferencemodel': 'settings',
'music.add_importbatch': 'library',
'federation.change_library': 'federation',
def main(command, **kwargs):
for codename, user_permission in sorted(mapping.items()):
app_label, c = codename.split('.')
p = Permission.objects.get(
content_type__app_label=app_label, codename=c)
users = models.User.objects.filter(
Q(groups__permissions=p) | Q(user_permissions=p)).distinct()
total = users.count()
command.stdout.write('Updating {} users with {} permission...'.format(
total, user_permission
users.update(**{'permission_{}'.format(user_permission): True})
This is a test script that does nothing.
You can launch it just to check how it works.
def main(command, **kwargs):
command.stdout.write('Test script run successfully')
......@@ -19,6 +19,9 @@ class MusicCacheDuration(types.IntPreference):
'locally? Federated files that were not listened in this interval '
'will be erased and refetched from the remote on the next listening.'
field_kwargs = {
'required': False,
......@@ -29,7 +32,7 @@ class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference):
verbose_name = 'Federation enabled'
help_text = (
'Use this setting to enable or disable federation logic and API'
' globally'
' globally.'
......@@ -41,8 +44,11 @@ class CollectionPageSize(
verbose_name = 'Federation collection page size'
help_text = (
'How much items to display in ActivityPub collections'
'How much items to display in ActivityPub collections.'
field_kwargs = {
'required': False,
......@@ -54,8 +60,11 @@ class ActorFetchDelay(
verbose_name = 'Federation actor fetch delay'
help_text = (
'How much minutes to wait before refetching actors on '
'request authentication'
'request authentication.'
field_kwargs = {
'required': False,
......@@ -66,6 +75,6 @@ class MusicNeedsApproval(
verbose_name = 'Federation music needs approval'
help_text = (
'When true, other federation actors will require your approval'
'When true, other federation actors will need your approval'
' before being able to browse your library.'
......@@ -233,6 +233,9 @@ class AudioMetadataFactory(factory.Factory):
release = factory.LazyAttribute(
lambda o: '{}'.format(uuid.uuid4())
bitrate = 42
length = 43
size = 44
class Meta:
model = dict
......@@ -216,3 +216,6 @@ class LibraryTrack(models.Model):
for chunk in r.iter_content(chunk_size=512):
tmp_file.write(chunk), tmp_file)
def get_metadata(self, key):
return self.metadata.get(key)
......@@ -688,6 +688,12 @@ class AudioMetadataSerializer(serializers.Serializer):
artist = ArtistMetadataSerializer()
release = ReleaseMetadataSerializer()
recording = RecordingMetadataSerializer()
bitrate = serializers.IntegerField(
required=False, allow_null=True, min_value=0)
size = serializers.IntegerField(
required=False, allow_null=True, min_value=0)
length = serializers.IntegerField(
required=False, allow_null=True, min_value=0)
class AudioSerializer(serializers.Serializer):
......@@ -760,6 +766,9 @@ class AudioSerializer(serializers.Serializer):
'musicbrainz_id': str(track.mbid) if track.mbid else None,
'title': track.title,
'bitrate': instance.bitrate,
'size': instance.size,
'length': instance.duration,
'url': {
'href': utils.full_url(instance.path),
......@@ -15,8 +15,8 @@ from rest_framework.serializers import ValidationError
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.common.permissions import HasModelPermission
from import TrackFile
from funkwhale_api.users.permissions import HasUserPermission
from . import activity
from . import actors
......@@ -88,7 +88,7 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
class WellKnownViewSet(viewsets.GenericViewSet):
authentication_classes = []
permission_classes = []
renderer_classes = [renderers.WebfingerRenderer]
renderer_classes = [renderers.JSONRenderer, renderers.WebfingerRenderer]
def nodeinfo(self, request, *args, **kwargs):
......@@ -187,16 +187,13 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
return response.Response(data)
class LibraryPermission(HasModelPermission):
model = models.Library
class LibraryViewSet(
permission_classes = [LibraryPermission]
permission_classes = (HasUserPermission,)
required_permissions = ['federation']
queryset = models.Library.objects.all().select_related(
......@@ -291,7 +288,8 @@ class LibraryViewSet(
class LibraryTrackViewSet(
permission_classes = [LibraryPermission]
permission_classes = (HasUserPermission,)
required_permissions = ['federation']
queryset = models.LibraryTrack.objects.all().select_related(
......@@ -13,8 +13,11 @@ class InstanceName(types.StringPreference):
section = instance
name = 'name'
default = ''
help_text = 'Instance public name'
verbose_name = 'The public name of your instance'
verbose_name = 'Public name'
help_text = 'The public name of your instance, displayed in the about page.'
field_kwargs = {
'required': False,
......@@ -23,7 +26,11 @@ class InstanceShortDescription(types.StringPreference):
section = instance
name = 'short_description'
default = ''
verbose_name = 'Instance succinct description'
verbose_name = 'Short description'
help_text = 'Instance succinct description, displayed in the about page.'
field_kwargs = {
'required': False,
......@@ -31,31 +38,31 @@ class InstanceLongDescription(types.StringPreference):
show_in_api = True
section = instance
name = 'long_description'
verbose_name = 'Long description'
default = ''
help_text = 'Instance long description (markdown allowed)'
help_text = 'Instance long description, displayed in the about page (markdown allowed).'
widget = widgets.Textarea
field_kwargs = {
'widget': widgets.Textarea
'required': False,
class RavenDSN(types.StringPreference):
show_in_api = True
section = raven
name = 'front_dsn'
default = ''
verbose_name = (
'A raven DSN key used to report front-ent errors to '
'a sentry instance'
verbose_name = 'Raven DSN key (front-end)'
help_text = (
'Keeping the default one will report errors to funkwhale developers'
'A Raven DSN key used to report front-ent errors to '
'a sentry instance. Keeping the default one will report errors to '
'Funkwhale developers.'
'Error reporting is disabled by default but you can enable it if'
' you want to help us improve funkwhale'
field_kwargs = {
'required': False,
......@@ -65,8 +72,7 @@ class RavenEnabled(types.BooleanPreference):
name = 'front_enabled'
default = False
verbose_name = (
'Wether error reporting to a Sentry instance using raven is enabled'
' for front-end errors'
'Report front-end errors with Raven'
......@@ -78,13 +84,27 @@ class InstanceNodeinfoEnabled(types.BooleanPreference):
default = True
verbose_name = 'Enable nodeinfo endpoint'
help_text = (
'This endpoint is needed for your about page to work.'
'This endpoint is needed for your about page to work. '
'It\'s also helpful for the various monitoring '
'tools that map and analyzize the fediverse, '
'but you can disable it completely if needed.'
class InstanceNodeinfoPrivate(types.BooleanPreference):
show_in_api = False
section = instance
name = 'nodeinfo_private'
default = False
verbose_name = 'Private mode in nodeinfo'
help_text = (
'Indicate in the nodeinfo endpoint that you do not want your instance '
'to be tracked by third-party services. '
'There is no guarantee these tools will honor this setting though.'
class InstanceNodeinfoStatsEnabled(types.BooleanPreference):
show_in_api = False
......@@ -93,6 +113,6 @@ class InstanceNodeinfoStatsEnabled(types.BooleanPreference):
default = True
verbose_name = 'Enable usage and library stats in nodeinfo endpoint'
help_text = (
'Disable this f you don\'t want to share usage and library statistics'
'Disable this if you don\'t want to share usage and library statistics '
'in the nodeinfo endpoint but don\'t want to disable it completely.'
......@@ -12,6 +12,7 @@ memo = memoize.Memoizer(store, namespace='instance:stats')
def get():
share_stats = preferences.get('instance__nodeinfo_stats_enabled')
private = preferences.get('instance__nodeinfo_private')
data = {
'version': '2.0',
'software': {
......@@ -30,6 +31,7 @@ def get():
'metadata': {
'private': preferences.get('instance__nodeinfo_private'),
'shortDescription': preferences.get('instance__short_description'),
'longDescription': preferences.get('instance__long_description'),
'nodeName': preferences.get('instance__name'),
from django.conf.urls import url
from rest_framework import routers
from . import views
admin_router = routers.SimpleRouter()
admin_router.register(r'admin/settings', views.AdminSettings, 'admin-settings')