Merge branch 'release/0.13'
This commit is contained in:
commit
7b18e46fc9
2
.env.dev
2
.env.dev
|
@ -9,3 +9,5 @@ FUNKWHALE_PROTOCOL=http
|
|||
PYTHONDONTWRITEBYTECODE=true
|
||||
WEBPACK_DEVSERVER_PORT=8080
|
||||
MUSIC_DIRECTORY_PATH=/music
|
||||
BROWSABLE_API_ENABLED=True
|
||||
CACHEOPS_ENABLED=False
|
||||
|
|
129
CHANGELOG
129
CHANGELOG
|
@ -10,6 +10,127 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.
|
|||
|
||||
.. towncrier
|
||||
|
||||
0.13 (2018-05-19)
|
||||
-----------------
|
||||
|
||||
Upgrade instructions are available at
|
||||
https://docs.funkwhale.audio/upgrading.html
|
||||
|
||||
Features:
|
||||
|
||||
- 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)
|
||||
|
||||
|
||||
Enhancements:
|
||||
|
||||
- Disabled browsable HTML API in production (#205)
|
||||
- Instances can now indicate on the nodeinfo endpoint if they want to remain
|
||||
private (#200)
|
||||
|
||||
|
||||
Bugfixes:
|
||||
|
||||
- .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 manage.py fix_track_files
|
||||
|
||||
|
||||
On non-docker setups:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
# from your activated virtualenv
|
||||
python manage.py 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 manage.py script django_permissions_to_user_permissions --no-input
|
||||
|
||||
On non-docker setups:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
# in your virtualenv
|
||||
python api/manage.py 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 = {
|
|||
'DEFAULT_FILTER_BACKENDS': (
|
||||
'rest_framework.filters.OrderingFilter',
|
||||
'django_filters.rest_framework.DjangoFilterBackend',
|
||||
),
|
||||
'DEFAULT_RENDERER_CLASSES': (
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
)
|
||||
}
|
||||
|
||||
BROWSABLE_API_ENABLED = env.bool('BROWSABLE_API_ENABLED', default=False)
|
||||
if BROWSABLE_API_ENABLED:
|
||||
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] += (
|
||||
'rest_framework.renderers.BrowsableAPIRenderer',
|
||||
)
|
||||
|
||||
REST_AUTH_SERIALIZERS = {
|
||||
'PASSWORD_RESET_SERIALIZER': 'funkwhale_api.users.serializers.PasswordResetSerializer' # noqa
|
||||
}
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
# -*- 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).'
|
||||
)
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
from django.core.management.base 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)
|
||||
parser.add_argument(
|
||||
'--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:
|
||||
self.show_help()
|
||||
|
||||
available_scripts = self.get_scripts()
|
||||
try:
|
||||
script = available_scripts[name]
|
||||
except KeyError:
|
||||
raise CommandError(
|
||||
'{} is not a valid script. Run python manage.py script for a '
|
||||
'list of available scripts'.format(name))
|
||||
|
||||
self.stdout.write('')
|
||||
if options['interactive']:
|
||||
message = (
|
||||
'Are you sure you want to execute the script {}?\n\n'
|
||||
"Type 'yes' to continue, or 'no' to cancel: "
|
||||
).format(name)
|
||||
if input(''.join(message)) != 'yes':
|
||||
raise CommandError("Script cancelled.")
|
||||
script['entrypoint'](self, **options)
|
||||
|
||||
def show_help(self):
|
||||
indentation = 4
|
||||
self.stdout.write('')
|
||||
self.stdout.write('Available scripts:')
|
||||
self.stdout.write('Launch with: python manage.py <script_name>')
|
||||
available_scripts = self.get_scripts()
|
||||
for name, script in sorted(available_scripts.items()):
|
||||
self.stdout.write('')
|
||||
self.stdout.write(self.style.SUCCESS(name))
|
||||
self.stdout.write('')
|
||||
for line in script['help'].splitlines():
|
||||
self.stdout.write(' {}'.format(line))
|
||||
self.stdout.write('')
|
||||
|
||||
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.
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
from . import django_permissions_to_user_permissions
|
||||
from . import test
|
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
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})
|
|
@ -0,0 +1,8 @@
|
|||
"""
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
|
@ -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(
|
|||
setting = 'FEDERATION_COLLECTION_PAGE_SIZE'
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
|
@ -66,6 +75,6 @@ class MusicNeedsApproval(
|
|||
setting = 'FEDERATION_MUSIC_NEEDS_APPROVAL'
|
||||
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: 'https://musicbrainz.org/release/{}'.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)
|
||||
self.audio_file.save(filename, 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 funkwhale_api.music.models 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]
|
||||
|
||||
@list_route(methods=['get'])
|
||||
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(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
permission_classes = [LibraryPermission]
|
||||
permission_classes = (HasUserPermission,)
|
||||
required_permissions = ['federation']
|
||||
queryset = models.Library.objects.all().select_related(
|
||||
'actor',
|
||||
'follow',
|
||||
|
@ -291,7 +288,8 @@ class LibraryViewSet(
|
|||
class LibraryTrackViewSet(
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
permission_classes = [LibraryPermission]
|
||||
permission_classes = (HasUserPermission,)
|
||||
required_permissions = ['federation']
|
||||
queryset = models.LibraryTrack.objects.all().select_related(
|
||||
'library__actor',
|
||||
'library__follow',
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class RavenDSN(types.StringPreference):
|
||||
show_in_api = True
|
||||
section = raven
|
||||
name = 'front_dsn'
|
||||
default = 'https://9e0562d46b09442bb8f6844e50cbca2b@sentry.eliotberriot.com/4'
|
||||
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.'
|
||||
)
|
||||
|
||||
|
||||
SENTRY_HELP_TEXT = (
|
||||
'Error reporting is disabled by default but you can enable it if'
|
||||
' you want to help us improve funkwhale'
|
||||
)
|
||||
field_kwargs = {
|
||||
'required': False,
|
||||
}
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
|
@ -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.'
|
||||
)
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
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.'
|
||||
)
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
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'),
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
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')
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^nodeinfo/2.0/$', views.NodeInfo.as_view(), name='nodeinfo-2.0'),
|
||||
url(r'^settings/$', views.InstanceSettings.as_view(), name='settings'),
|
||||
]
|
||||
] + admin_router.urls
|
||||
|
|
|
@ -2,9 +2,11 @@ from rest_framework import views
|
|||
from rest_framework.response import Response
|
||||
|
||||
from dynamic_preferences.api import serializers
|
||||
from dynamic_preferences.api import viewsets as preferences_viewsets
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
from funkwhale_api.common import preferences
|
||||
from funkwhale_api.users.permissions import HasUserPermission
|
||||
|
||||
from . import nodeinfo
|
||||
from . import stats
|
||||
|
@ -15,6 +17,11 @@ NODEINFO_2_CONTENT_TYPE = (
|
|||
)
|
||||
|
||||
|
||||
class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet):
|
||||
pagination_class = None
|
||||
permission_classes = (HasUserPermission,)
|
||||
required_permissions = ['settings']
|
||||
|
||||
class InstanceSettings(views.APIView):
|
||||
permission_classes = []
|
||||
authentication_classes = []
|
||||
|
|
|
@ -38,6 +38,7 @@ class ImportBatchAdmin(admin.ModelAdmin):
|
|||
search_fields = [
|
||||
'import_request__name', 'source', 'batch__pk', 'mbid']
|
||||
|
||||
|
||||
@admin.register(models.ImportJob)
|
||||
class ImportJobAdmin(admin.ModelAdmin):
|
||||
list_display = ['source', 'batch', 'track_file', 'status', 'mbid']
|
||||
|
@ -73,9 +74,16 @@ class TrackFileAdmin(admin.ModelAdmin):
|
|||
'source',
|
||||
'duration',
|
||||
'mimetype',
|
||||
'size',
|
||||
'bitrate'
|
||||
]
|
||||
list_select_related = [
|
||||
'track'
|
||||
]
|
||||
search_fields = ['source', 'acoustid_track_id']
|
||||
search_fields = [
|
||||
'source',
|
||||
'acoustid_track_id',
|
||||
'track__title',
|
||||
'track__album__title',
|
||||
'track__artist__name']
|
||||
list_filter = ['mimetype']
|
||||
|
|
|
@ -54,6 +54,10 @@ class TrackFileFactory(factory.django.DjangoModelFactory):
|
|||
audio_file = factory.django.FileField(
|
||||
from_path=os.path.join(SAMPLES_PATH, 'test.ogg'))
|
||||
|
||||
bitrate = None
|
||||
size = None
|
||||
duration = None
|
||||
|
||||
class Meta:
|
||||
model = 'music.TrackFile'
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import cacheops
|
|||
import os
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
|
@ -24,6 +25,8 @@ class Command(BaseCommand):
|
|||
if options['dry_run']:
|
||||
self.stdout.write('Dry-run on, will not commit anything')
|
||||
self.fix_mimetypes(**options)
|
||||
self.fix_file_data(**options)
|
||||
self.fix_file_size(**options)
|
||||
cacheops.invalidate_model(models.TrackFile)
|
||||
|
||||
@transaction.atomic
|
||||
|
@ -43,3 +46,60 @@ class Command(BaseCommand):
|
|||
if not dry_run:
|
||||
self.stdout.write('[mimetypes] commiting...')
|
||||
qs.update(mimetype=mimetype)
|
||||
|
||||
def fix_file_data(self, dry_run, **kwargs):
|
||||
self.stdout.write('Fixing missing bitrate or length...')
|
||||
matching = models.TrackFile.objects.filter(
|
||||
Q(bitrate__isnull=True) | Q(duration__isnull=True))
|
||||
total = matching.count()
|
||||
self.stdout.write(
|
||||
'[bitrate/length] {} entries found with missing values'.format(
|
||||
total))
|
||||
if dry_run:
|
||||
return
|
||||
for i, tf in enumerate(matching.only('audio_file')):
|
||||
self.stdout.write(
|
||||
'[bitrate/length] {}/{} fixing file #{}'.format(
|
||||
i+1, total, tf.pk
|
||||
))
|
||||
|
||||
try:
|
||||
audio_file = tf.get_audio_file()
|
||||
if audio_file:
|
||||
with audio_file as f:
|
||||
data = utils.get_audio_file_data(audio_file)
|
||||
tf.bitrate = data['bitrate']
|
||||
tf.duration = data['length']
|
||||
tf.save(update_fields=['duration', 'bitrate'])
|
||||
else:
|
||||
self.stderr.write('[bitrate/length] no file found')
|
||||
except Exception as e:
|
||||
self.stderr.write(
|
||||
'[bitrate/length] error with file #{}: {}'.format(
|
||||
tf.pk, str(e)
|
||||
)
|
||||
)
|
||||
|
||||
def fix_file_size(self, dry_run, **kwargs):
|
||||
self.stdout.write('Fixing missing size...')
|
||||
matching = models.TrackFile.objects.filter(size__isnull=True)
|
||||
total = matching.count()
|
||||
self.stdout.write(
|
||||
'[size] {} entries found with missing values'.format(total))
|
||||
if dry_run:
|
||||
return
|
||||
for i, tf in enumerate(matching.only('size')):
|
||||
self.stdout.write(
|
||||
'[size] {}/{} fixing file #{}'.format(
|
||||
i+1, total, tf.pk
|
||||
))
|
||||
|
||||
try:
|
||||
tf.size = tf.get_file_size()
|
||||
tf.save(update_fields=['size'])
|
||||
except Exception as e:
|
||||
self.stderr.write(
|
||||
'[size] error with file #{}: {}'.format(
|
||||
tf.pk, str(e)
|
||||
)
|
||||
)
|
||||
|
|
|
@ -28,6 +28,13 @@ def get_id3_tag(f, k):
|
|||
raise TagNotFound(k)
|
||||
|
||||
|
||||
def get_flac_tag(f, k):
|
||||
try:
|
||||
return f.get(k)[0]
|
||||
except (KeyError, IndexError):
|
||||
raise TagNotFound(k)
|
||||
|
||||
|
||||
def get_mp3_recording_id(f, k):
|
||||
try:
|
||||
return [
|
||||
|
@ -121,7 +128,38 @@ CONF = {
|
|||
'getter': get_mp3_recording_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
'FLAC': {
|
||||
'getter': get_flac_tag,
|
||||
'fields': {
|
||||
'track_number': {
|
||||
'field': 'tracknumber',
|
||||
'to_application': convert_track_number
|
||||
},
|
||||
'title': {
|
||||
'field': 'title'
|
||||
},
|
||||
'artist': {
|
||||
'field': 'artist'
|
||||
},
|
||||
'album': {
|
||||
'field': 'album'
|
||||
},
|
||||
'date': {
|
||||
'field': 'date',
|
||||
'to_application': lambda v: arrow.get(str(v)).date()
|
||||
},
|
||||
'musicbrainz_albumid': {
|
||||
'field': 'musicbrainz_albumid'
|
||||
},
|
||||
'musicbrainz_artistid': {
|
||||
'field': 'musicbrainz_artistid'
|
||||
},
|
||||
'musicbrainz_recordingid': {
|
||||
'field': 'musicbrainz_trackid'
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 2.0.3 on 2018-05-15 18:08
|
||||
|
||||
from django.db import migrations, models
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0026_trackfile_accessed_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='trackfile',
|
||||
name='bitrate',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='trackfile',
|
||||
name='size',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='track',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
|
||||
),
|
||||
]
|
|
@ -361,7 +361,7 @@ class Track(APIModelMixin):
|
|||
import_tags
|
||||
]
|
||||
objects = TrackQuerySet.as_manager()
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['album', 'position']
|
||||
|
@ -429,6 +429,8 @@ class TrackFile(models.Model):
|
|||
modification_date = models.DateTimeField(auto_now=True)
|
||||
accessed_date = models.DateTimeField(null=True, blank=True)
|
||||
duration = models.IntegerField(null=True, blank=True)
|
||||
size = models.IntegerField(null=True, blank=True)
|
||||
bitrate = models.IntegerField(null=True, blank=True)
|
||||
acoustid_track_id = models.UUIDField(null=True, blank=True)
|
||||
mimetype = models.CharField(null=True, blank=True, max_length=200)
|
||||
|
||||
|
@ -467,7 +469,7 @@ class TrackFile(models.Model):
|
|||
|
||||
@property
|
||||
def filename(self):
|
||||
return '{}{}'.format(
|
||||
return '{}.{}'.format(
|
||||
self.track.full_name,
|
||||
self.extension)
|
||||
|
||||
|
@ -477,6 +479,41 @@ class TrackFile(models.Model):
|
|||
return
|
||||
return os.path.splitext(self.audio_file.name)[-1].replace('.', '', 1)
|
||||
|
||||
def get_file_size(self):
|
||||
if self.audio_file:
|
||||
return self.audio_file.size
|
||||
|
||||
if self.source.startswith('file://'):
|
||||
return os.path.getsize(self.source.replace('file://', '', 1))
|
||||
|
||||
if self.library_track and self.library_track.audio_file:
|
||||
return self.library_track.audio_file.size
|
||||
|
||||
def get_audio_file(self):
|
||||
if self.audio_file:
|
||||
return self.audio_file.open()
|
||||
if self.source.startswith('file://'):
|
||||
return open(self.source.replace('file://', '', 1), 'rb')
|
||||
if self.library_track and self.library_track.audio_file:
|
||||
return self.library_track.audio_file.open()
|
||||
|
||||
def set_audio_data(self):
|
||||
audio_file = self.get_audio_file()
|
||||
if audio_file:
|
||||
with audio_file as f:
|
||||
audio_data = utils.get_audio_file_data(f)
|
||||
if not audio_data:
|
||||
return
|
||||
self.duration = int(audio_data['length'])
|
||||
self.bitrate = audio_data['bitrate']
|
||||
self.size = self.get_file_size()
|
||||
else:
|
||||
lt = self.library_track
|
||||
if lt:
|
||||
self.duration = lt.get_metadata('length')
|
||||
self.size = lt.get_metadata('size')
|
||||
self.bitrate = lt.get_metadata('bitrate')
|
||||
|
||||
def save(self, **kwargs):
|
||||
if not self.mimetype and self.audio_file:
|
||||
self.mimetype = utils.guess_mimetype(self.audio_file)
|
||||
|
|
|
@ -27,6 +27,7 @@ class SimpleArtistSerializer(serializers.ModelSerializer):
|
|||
|
||||
class ArtistSerializer(serializers.ModelSerializer):
|
||||
tags = TagSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Artist
|
||||
fields = ('id', 'mbid', 'name', 'tags', 'creation_date')
|
||||
|
@ -40,11 +41,21 @@ class TrackFileSerializer(serializers.ModelSerializer):
|
|||
fields = (
|
||||
'id',
|
||||
'path',
|
||||
'duration',
|
||||
'source',
|
||||
'filename',
|
||||
'mimetype',
|
||||
'track')
|
||||
'track',
|
||||
'duration',
|
||||
'mimetype',
|
||||
'bitrate',
|
||||
'size',
|
||||
)
|
||||
read_only_fields = [
|
||||
'duration',
|
||||
'mimetype',
|
||||
'bitrate',
|
||||
'size',
|
||||
]
|
||||
|
||||
def get_path(self, o):
|
||||
url = o.path
|
||||
|
|
|
@ -134,6 +134,7 @@ def _do_import(import_job, replace=False, use_acoustid=True):
|
|||
# in place import, we set mimetype from extension
|
||||
path, ext = os.path.splitext(import_job.source)
|
||||
track_file.mimetype = music_utils.get_type_from_ext(ext)
|
||||
track_file.set_audio_data()
|
||||
track_file.save()
|
||||
import_job.status = 'finished'
|
||||
import_job.track_file = track_file
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import magic
|
||||
import mimetypes
|
||||
import mutagen
|
||||
import re
|
||||
|
||||
from django.db.models import Q
|
||||
|
@ -66,6 +67,7 @@ def compute_status(jobs):
|
|||
AUDIO_EXTENSIONS_AND_MIMETYPE = [
|
||||
('ogg', 'audio/ogg'),
|
||||
('mp3', 'audio/mpeg'),
|
||||
('flac', 'audio/x-flac'),
|
||||
]
|
||||
|
||||
EXTENSION_TO_MIMETYPE = {ext: mt for ext, mt in AUDIO_EXTENSIONS_AND_MIMETYPE}
|
||||
|
@ -81,3 +83,14 @@ def get_type_from_ext(extension):
|
|||
# we remove leading dot
|
||||
extension = extension[1:]
|
||||
return EXTENSION_TO_MIMETYPE.get(extension)
|
||||
|
||||
|
||||
def get_audio_file_data(f):
|
||||
data = mutagen.File(f)
|
||||
if not data:
|
||||
return
|
||||
d = {}
|
||||
d['bitrate'] = data.info.bitrate
|
||||
d['length'] = data.info.length
|
||||
|
||||
return d
|
||||
|
|
|
@ -25,8 +25,8 @@ from rest_framework import permissions
|
|||
from musicbrainzngs import ResponseError
|
||||
|
||||
from funkwhale_api.common import utils as funkwhale_utils
|
||||
from funkwhale_api.common.permissions import (
|
||||
ConditionalAuthentication, HasModelPermission)
|
||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
||||
from funkwhale_api.users.permissions import HasUserPermission
|
||||
from taggit.models import Tag
|
||||
from funkwhale_api.federation import actors
|
||||
from funkwhale_api.federation.authentication import SignatureAuthentication
|
||||
|
@ -107,25 +107,22 @@ class ImportBatchViewSet(
|
|||
.annotate(job_count=Count('jobs'))
|
||||
)
|
||||
serializer_class = serializers.ImportBatchSerializer
|
||||
permission_classes = (permissions.DjangoModelPermissions, )
|
||||
permission_classes = (HasUserPermission,)
|
||||
required_permissions = ['library']
|
||||
filter_class = filters.ImportBatchFilter
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(submitted_by=self.request.user)
|
||||
|
||||
|
||||
class ImportJobPermission(HasModelPermission):
|
||||
# not a typo, perms on import job is proxied to import batch
|
||||
model = models.ImportBatch
|
||||
|
||||
|
||||
class ImportJobViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
queryset = (models.ImportJob.objects.all().select_related())
|
||||
serializer_class = serializers.ImportJobSerializer
|
||||
permission_classes = (ImportJobPermission, )
|
||||
permission_classes = (HasUserPermission,)
|
||||
required_permissions = ['library']
|
||||
filter_class = filters.ImportJobFilter
|
||||
|
||||
@list_route(methods=['get'])
|
||||
|
@ -230,7 +227,7 @@ def get_file_path(audio_file):
|
|||
'MUSIC_DIRECTORY_PATH to serve in-place imported files'
|
||||
)
|
||||
path = '/music' + audio_file.replace(prefix, '', 1)
|
||||
return settings.PROTECT_FILES_PATH + path
|
||||
return (settings.PROTECT_FILES_PATH + path).encode('utf-8')
|
||||
if t == 'apache2':
|
||||
try:
|
||||
path = audio_file.path
|
||||
|
@ -241,7 +238,7 @@ def get_file_path(audio_file):
|
|||
'You need to specify MUSIC_DIRECTORY_SERVE_PATH and '
|
||||
'MUSIC_DIRECTORY_PATH to serve in-place imported files'
|
||||
)
|
||||
path = audio_file.replace(prefix, serve_path, 1)
|
||||
path = audio_file.replace(prefix, serve_path, 1).encode('utf-8')
|
||||
return path
|
||||
|
||||
|
||||
|
@ -268,6 +265,10 @@ def handle_serve(track_file):
|
|||
qs = LibraryTrack.objects.select_for_update()
|
||||
library_track = qs.get(pk=library_track.pk)
|
||||
library_track.download_audio()
|
||||
track_file.library_track = library_track
|
||||
track_file.set_audio_data()
|
||||
track_file.save(update_fields=['bitrate', 'duration', 'size'])
|
||||
|
||||
audio_file = library_track.audio_file
|
||||
file_path = get_file_path(audio_file)
|
||||
mt = library_track.audio_mimetype
|
||||
|
@ -275,7 +276,10 @@ def handle_serve(track_file):
|
|||
file_path = get_file_path(audio_file)
|
||||
elif f.source and f.source.startswith('file://'):
|
||||
file_path = get_file_path(f.source.replace('file://', '', 1))
|
||||
response = Response()
|
||||
if mt:
|
||||
response = Response(content_type=mt)
|
||||
else:
|
||||
response = Response()
|
||||
filename = f.filename
|
||||
mapping = {
|
||||
'nginx': 'X-Accel-Redirect',
|
||||
|
@ -293,7 +297,11 @@ def handle_serve(track_file):
|
|||
|
||||
|
||||
class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = (models.TrackFile.objects.all().order_by('-id'))
|
||||
queryset = (
|
||||
models.TrackFile.objects.all()
|
||||
.select_related('track__artist', 'track__album')
|
||||
.order_by('-id')
|
||||
)
|
||||
serializer_class = serializers.TrackFileSerializer
|
||||
authentication_classes = rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES + [
|
||||
SignatureAuthentication
|
||||
|
@ -431,7 +439,8 @@ class Search(views.APIView):
|
|||
|
||||
class SubmitViewSet(viewsets.ViewSet):
|
||||
queryset = models.ImportBatch.objects.none()
|
||||
permission_classes = (permissions.DjangoModelPermissions, )
|
||||
permission_classes = (HasUserPermission,)
|
||||
required_permissions = ['library']
|
||||
|
||||
@list_route(methods=['post'])
|
||||
@transaction.non_atomic_requests
|
||||
|
|
|
@ -13,3 +13,6 @@ class MaxTracks(preferences.DefaultFromSettingMixin, types.IntegerPreference):
|
|||
name = 'max_tracks'
|
||||
verbose_name = 'Max tracks per playlist'
|
||||
setting = 'PLAYLISTS_MAX_TRACKS'
|
||||
field_kwargs = {
|
||||
'required': False,
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from django import forms
|
||||
|
||||
from dynamic_preferences.types import StringPreference, Section
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
|
@ -11,3 +13,7 @@ class APIKey(StringPreference):
|
|||
default = ''
|
||||
verbose_name = 'Acoustid API key'
|
||||
help_text = 'The API key used to query AcoustID. Get one at https://acoustid.org/new-application.'
|
||||
widget = forms.PasswordInput
|
||||
field_kwargs = {
|
||||
'required': False,
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from django import forms
|
||||
|
||||
from dynamic_preferences.types import StringPreference, Section
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
|
@ -11,3 +13,7 @@ class APIKey(StringPreference):
|
|||
default = 'CHANGEME'
|
||||
verbose_name = 'YouTube API key'
|
||||
help_text = 'The API key used to query YouTube. Get one at https://console.developers.google.com/.'
|
||||
widget = forms.PasswordInput
|
||||
field_kwargs = {
|
||||
'required': False,
|
||||
}
|
||||
|
|
|
@ -15,6 +15,9 @@ class SubsonicJSONRenderer(renderers.JSONRenderer):
|
|||
}
|
||||
}
|
||||
final['subsonic-response'].update(data)
|
||||
if 'error' in final:
|
||||
# an error was returned
|
||||
final['subsonic-response']['status'] = 'failed'
|
||||
return super().render(final, accepted_media_type, renderer_context)
|
||||
|
||||
|
||||
|
@ -31,6 +34,9 @@ class SubsonicXMLRenderer(renderers.JSONRenderer):
|
|||
'version': '1.16.0',
|
||||
}
|
||||
final.update(data)
|
||||
if 'error' in final:
|
||||
# an error was returned
|
||||
final['status'] = 'failed'
|
||||
tree = dict_to_xml_tree('subsonic-response', final)
|
||||
return b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(tree, encoding='utf-8')
|
||||
|
||||
|
|
|
@ -81,6 +81,10 @@ def get_track_data(album, track, tf):
|
|||
'artistId': album.artist.pk,
|
||||
'type': 'music',
|
||||
}
|
||||
if tf.bitrate:
|
||||
data['bitrate'] = int(tf.bitrate/1000)
|
||||
if tf.size:
|
||||
data['size'] = tf.size
|
||||
if album.release_date:
|
||||
data['year'] = album.release_date.year
|
||||
return data
|
||||
|
@ -211,5 +215,9 @@ def get_music_directory_data(artist):
|
|||
'parent': artist.id,
|
||||
'type': 'music',
|
||||
}
|
||||
if tf.bitrate:
|
||||
td['bitrate'] = int(tf.bitrate/1000)
|
||||
if tf.size:
|
||||
td['size'] = tf.size
|
||||
data['child'].append(td)
|
||||
return data
|
||||
|
|
|
@ -31,15 +31,19 @@ def find_object(queryset, model_field='pk', field='id', cast=int):
|
|||
raw_value = data[field]
|
||||
except KeyError:
|
||||
return response.Response({
|
||||
'code': 10,
|
||||
'message': "required parameter '{}' not present".format(field)
|
||||
'error': {
|
||||
'code': 10,
|
||||
'message': "required parameter '{}' not present".format(field)
|
||||
}
|
||||
})
|
||||
try:
|
||||
value = cast(raw_value)
|
||||
except (TypeError, ValidationError):
|
||||
return response.Response({
|
||||
'code': 0,
|
||||
'message': 'For input string "{}"'.format(raw_value)
|
||||
'error': {
|
||||
'code': 0,
|
||||
'message': 'For input string "{}"'.format(raw_value)
|
||||
}
|
||||
})
|
||||
qs = queryset
|
||||
if hasattr(qs, '__call__'):
|
||||
|
@ -48,9 +52,11 @@ def find_object(queryset, model_field='pk', field='id', cast=int):
|
|||
obj = qs.get(**{model_field: value})
|
||||
except qs.model.DoesNotExist:
|
||||
return response.Response({
|
||||
'code': 70,
|
||||
'message': '{} not found'.format(
|
||||
qs.model.__class__.__name__)
|
||||
'error': {
|
||||
'code': 70,
|
||||
'message': '{} not found'.format(
|
||||
qs.model.__class__.__name__)
|
||||
}
|
||||
})
|
||||
kwargs['obj'] = obj
|
||||
return func(self, request, *args, **kwargs)
|
||||
|
@ -83,15 +89,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
|
|||
payload = {
|
||||
'status': 'failed'
|
||||
}
|
||||
try:
|
||||
if exc.__class__ in mapping:
|
||||
code, message = mapping[exc.__class__]
|
||||
except KeyError:
|
||||
return super().handle_exception(exc)
|
||||
else:
|
||||
payload['error'] = {
|
||||
'code': code,
|
||||
'message': message
|
||||
}
|
||||
return super().handle_exception(exc)
|
||||
payload['error'] = {
|
||||
'code': code,
|
||||
'message': message
|
||||
}
|
||||
|
||||
return response.Response(payload, status=200)
|
||||
|
||||
|
@ -450,8 +455,10 @@ class SubsonicViewSet(viewsets.GenericViewSet):
|
|||
name = data.get('name', '')
|
||||
if not name:
|
||||
return response.Response({
|
||||
'code': 10,
|
||||
'message': 'Playlist ID or name must be specified.'
|
||||
'error': {
|
||||
'code': 10,
|
||||
'message': 'Playlist ID or name must be specified.'
|
||||
}
|
||||
}, data)
|
||||
|
||||
playlist = request.user.playlists.create(
|
||||
|
|
|
@ -5,6 +5,7 @@ from django import forms
|
|||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
|
||||
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from .models import User
|
||||
|
||||
|
@ -41,8 +42,33 @@ class UserAdmin(AuthUserAdmin):
|
|||
'email',
|
||||
'date_joined',
|
||||
'last_login',
|
||||
'privacy_level',
|
||||
'is_staff',
|
||||
'is_superuser',
|
||||
]
|
||||
list_filter = [
|
||||
'is_superuser',
|
||||
'is_staff',
|
||||
'privacy_level',
|
||||
'permission_settings',
|
||||
'permission_library',
|
||||
'permission_federation',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('username', 'password', 'privacy_level')}),
|
||||
(_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}),
|
||||
(_('Permissions'), {
|
||||
'fields': (
|
||||
'is_active',
|
||||
'is_staff',
|
||||
'is_superuser',
|
||||
'permission_library',
|
||||
'permission_settings',
|
||||
'permission_federation')}),
|
||||
(_('Important dates'), {'fields': ('last_login', 'date_joined')}),
|
||||
(_('Useless fields'), {
|
||||
'fields': (
|
||||
'user_permissions',
|
||||
'groups',
|
||||
)})
|
||||
)
|
||||
|
|
|
@ -10,6 +10,7 @@ class RegistrationEnabled(types.BooleanPreference):
|
|||
section = users
|
||||
name = 'registration_enabled'
|
||||
default = False
|
||||
verbose_name = (
|
||||
'Can visitors open a new account on this instance?'
|
||||
verbose_name = 'Open registrations to new users'
|
||||
help_text = (
|
||||
'When enabled, new users will be able to register on this instance.'
|
||||
)
|
||||
|
|
|
@ -1,15 +1,41 @@
|
|||
import factory
|
||||
|
||||
from funkwhale_api.factories import registry
|
||||
from funkwhale_api.factories import registry, ManyToManyFromList
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
|
||||
@registry.register
|
||||
class GroupFactory(factory.django.DjangoModelFactory):
|
||||
name = factory.Sequence(lambda n: 'group-{0}'.format(n))
|
||||
|
||||
class Meta:
|
||||
model = 'auth.Group'
|
||||
|
||||
@factory.post_generation
|
||||
def perms(self, create, extracted, **kwargs):
|
||||
if not create:
|
||||
# Simple build, do nothing.
|
||||
return
|
||||
|
||||
if extracted:
|
||||
perms = [
|
||||
Permission.objects.get(
|
||||
content_type__app_label=p.split('.')[0],
|
||||
codename=p.split('.')[1],
|
||||
)
|
||||
for p in extracted
|
||||
]
|
||||
# A list of permissions were passed in, use them
|
||||
self.permissions.add(*perms)
|
||||
|
||||
|
||||
@registry.register
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
username = factory.Sequence(lambda n: 'user-{0}'.format(n))
|
||||
email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n))
|
||||
password = factory.PostGenerationMethodCall('set_password', 'test')
|
||||
subsonic_api_token = None
|
||||
groups = ManyToManyFromList('groups')
|
||||
|
||||
class Meta:
|
||||
model = 'users.User'
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 2.0.4 on 2018-05-17 23:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0005_user_subsonic_api_token'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='permission_federation',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='permission_library',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='permission_settings',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
|
@ -1,11 +1,12 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import binascii
|
||||
import os
|
||||
import uuid
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.auth.models import AbstractUser, Permission
|
||||
from django.urls import reverse
|
||||
from django.db import models
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
|
@ -14,6 +15,17 @@ from django.utils.translation import ugettext_lazy as _
|
|||
from funkwhale_api.common import fields
|
||||
|
||||
|
||||
def get_token():
|
||||
return binascii.b2a_hex(os.urandom(15)).decode('utf-8')
|
||||
|
||||
|
||||
PERMISSIONS = [
|
||||
'federation',
|
||||
'library',
|
||||
'settings',
|
||||
]
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class User(AbstractUser):
|
||||
|
||||
|
@ -23,20 +35,6 @@ class User(AbstractUser):
|
|||
|
||||
# updated on logout or password change, to invalidate JWT
|
||||
secret_key = models.UUIDField(default=uuid.uuid4, null=True)
|
||||
# permissions that are used for API access and that worth serializing
|
||||
relevant_permissions = {
|
||||
# internal_codename : {external_codename}
|
||||
'music.add_importbatch': {
|
||||
'external_codename': 'import.launch',
|
||||
},
|
||||
'dynamic_preferences.change_globalpreferencemodel': {
|
||||
'external_codename': 'settings.change',
|
||||
},
|
||||
'federation.change_library': {
|
||||
'external_codename': 'federation.manage',
|
||||
},
|
||||
}
|
||||
|
||||
privacy_level = fields.get_privacy_field()
|
||||
|
||||
# Unfortunately, Subsonic API assumes a MD5/password authentication
|
||||
|
@ -47,9 +45,33 @@ class User(AbstractUser):
|
|||
subsonic_api_token = models.CharField(
|
||||
blank=True, null=True, max_length=255)
|
||||
|
||||
# permissions
|
||||
permission_federation = models.BooleanField(
|
||||
'Manage library federation',
|
||||
help_text='Follow other instances, accept/deny library follow requests...',
|
||||
default=False)
|
||||
permission_library = models.BooleanField(
|
||||
'Manage library',
|
||||
help_text='Import new content, manage existing content',
|
||||
default=False)
|
||||
permission_settings = models.BooleanField(
|
||||
'Manage instance-level settings',
|
||||
default=False)
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
def get_permissions(self):
|
||||
perms = {}
|
||||
for p in PERMISSIONS:
|
||||
v = self.is_superuser or getattr(self, 'permission_{}'.format(p))
|
||||
perms[p] = v
|
||||
return perms
|
||||
|
||||
def has_permissions(self, *perms):
|
||||
permissions = self.get_permissions()
|
||||
return all([permissions[p] for p in perms])
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('users:detail', kwargs={'username': self.username})
|
||||
|
||||
|
@ -58,7 +80,7 @@ class User(AbstractUser):
|
|||
return self.secret_key
|
||||
|
||||
def update_subsonic_api_token(self):
|
||||
self.subsonic_api_token = secrets.token_hex(32)
|
||||
self.subsonic_api_token = get_token()
|
||||
return self.subsonic_api_token
|
||||
|
||||
def set_password(self, raw_password):
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
from rest_framework.permissions import BasePermission
|
||||
|
||||
|
||||
class HasUserPermission(BasePermission):
|
||||
"""
|
||||
Ensure the request user has the proper permissions.
|
||||
|
||||
Usage:
|
||||
|
||||
class MyView(APIView):
|
||||
permission_classes = [HasUserPermission]
|
||||
required_permissions = ['federation']
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
if not hasattr(request, 'user') or not request.user:
|
||||
return False
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
return request.user.has_permissions(*view.required_permissions)
|
|
@ -55,16 +55,11 @@ class UserReadSerializer(serializers.ModelSerializer):
|
|||
'is_superuser',
|
||||
'permissions',
|
||||
'date_joined',
|
||||
'privacy_level'
|
||||
'privacy_level',
|
||||
]
|
||||
|
||||
def get_permissions(self, o):
|
||||
perms = {}
|
||||
for internal_codename, conf in o.relevant_permissions.items():
|
||||
perms[conf['external_codename']] = {
|
||||
'status': o.has_perm(internal_codename)
|
||||
}
|
||||
return perms
|
||||
return o.get_permissions()
|
||||
|
||||
|
||||
class PasswordResetSerializer(PRS):
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import pytest
|
||||
|
||||
from funkwhale_api.common.management.commands import script
|
||||
from funkwhale_api.common import scripts
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def command():
|
||||
return script.Command()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('script_name', [
|
||||
'django_permissions_to_user_permissions',
|
||||
'test',
|
||||
])
|
||||
def test_script_command_list(command, script_name, mocker):
|
||||
mocked = mocker.patch(
|
||||
'funkwhale_api.common.scripts.{}.main'.format(script_name))
|
||||
|
||||
command.handle(script_name=script_name, interactive=False)
|
||||
|
||||
mocked.assert_called_once_with(
|
||||
command, script_name=script_name, interactive=False)
|
||||
|
||||
|
||||
def test_django_permissions_to_user_permissions(factories, command):
|
||||
group = factories['auth.Group'](
|
||||
perms=[
|
||||
'federation.change_library'
|
||||
]
|
||||
)
|
||||
user1 = factories['users.User'](
|
||||
perms=[
|
||||
'dynamic_preferences.change_globalpreferencemodel',
|
||||
'music.add_importbatch',
|
||||
]
|
||||
)
|
||||
user2 = factories['users.User'](
|
||||
perms=[
|
||||
'music.add_importbatch',
|
||||
],
|
||||
groups=[group]
|
||||
)
|
||||
|
||||
scripts.django_permissions_to_user_permissions.main(command)
|
||||
|
||||
user1.refresh_from_db()
|
||||
user2.refresh_from_db()
|
||||
|
||||
assert user1.permission_settings is True
|
||||
assert user1.permission_library is True
|
||||
assert user1.permission_federation is False
|
||||
|
||||
assert user2.permission_settings is False
|
||||
assert user2.permission_library is True
|
||||
assert user2.permission_federation is True
|
|
@ -14,6 +14,7 @@ from rest_framework.test import APIClient
|
|||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from funkwhale_api.activity import record
|
||||
from funkwhale_api.users.permissions import HasUserPermission
|
||||
from funkwhale_api.taskapp import celery
|
||||
|
||||
|
||||
|
@ -224,3 +225,11 @@ def authenticated_actor(factories, mocker):
|
|||
'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor',
|
||||
return_value=actor)
|
||||
yield actor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def assert_user_permission():
|
||||
def inner(view, permissions):
|
||||
assert HasUserPermission in view.permission_classes
|
||||
assert set(view.required_permissions) == set(permissions)
|
||||
return inner
|
||||
|
|
|
@ -533,7 +533,12 @@ def test_activity_pub_audio_serializer_to_library_track_no_duplicate(
|
|||
|
||||
|
||||
def test_activity_pub_audio_serializer_to_ap(factories):
|
||||
tf = factories['music.TrackFile'](mimetype='audio/mp3')
|
||||
tf = factories['music.TrackFile'](
|
||||
mimetype='audio/mp3',
|
||||
bitrate=42,
|
||||
duration=43,
|
||||
size=44,
|
||||
)
|
||||
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
expected = {
|
||||
'@context': serializers.AP_CONTEXT,
|
||||
|
@ -555,6 +560,9 @@ def test_activity_pub_audio_serializer_to_ap(factories):
|
|||
'musicbrainz_id': tf.track.mbid,
|
||||
'title': tf.track.title,
|
||||
},
|
||||
'size': tf.size,
|
||||
'length': tf.duration,
|
||||
'bitrate': tf.bitrate,
|
||||
},
|
||||
'url': {
|
||||
'href': utils.full_url(tf.path),
|
||||
|
@ -599,6 +607,9 @@ def test_activity_pub_audio_serializer_to_ap_no_mbid(factories):
|
|||
'title': tf.track.title,
|
||||
'musicbrainz_id': None,
|
||||
},
|
||||
'size': None,
|
||||
'length': None,
|
||||
'bitrate': None,
|
||||
},
|
||||
'url': {
|
||||
'href': utils.full_url(tf.path),
|
||||
|
|
|
@ -9,9 +9,18 @@ from funkwhale_api.federation import activity
|
|||
from funkwhale_api.federation import models
|
||||
from funkwhale_api.federation import serializers
|
||||
from funkwhale_api.federation import utils
|
||||
from funkwhale_api.federation import views
|
||||
from funkwhale_api.federation import webfinger
|
||||
|
||||
|
||||
@pytest.mark.parametrize('view,permissions', [
|
||||
(views.LibraryViewSet, ['federation']),
|
||||
(views.LibraryTrackViewSet, ['federation']),
|
||||
])
|
||||
def test_permissions(assert_user_permission, view, permissions):
|
||||
assert_user_permission(view, permissions)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
|
||||
def test_instance_actors(system_actor, db, api_client):
|
||||
actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
|
||||
|
@ -62,7 +71,10 @@ def test_wellknown_webfinger_system(
|
|||
actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
|
||||
url = reverse('federation:well-known-webfinger')
|
||||
response = api_client.get(
|
||||
url, data={'resource': 'acct:{}'.format(actor.webfinger_subject)})
|
||||
url,
|
||||
data={'resource': 'acct:{}'.format(actor.webfinger_subject)},
|
||||
HTTP_ACCEPT='application/jrd+json',
|
||||
)
|
||||
serializer = serializers.ActorWebfingerSerializer(actor)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
@ -83,7 +95,7 @@ def test_wellknown_nodeinfo(db, preferences, api_client, settings):
|
|||
]
|
||||
}
|
||||
url = reverse('federation:well-known-nodeinfo')
|
||||
response = api_client.get(url)
|
||||
response = api_client.get(url, HTTP_ACCEPT='application/jrd+json')
|
||||
assert response.status_code == 200
|
||||
assert response['Content-Type'] == 'application/jrd+json'
|
||||
assert response.data == expected
|
||||
|
|
|
@ -36,6 +36,7 @@ def test_nodeinfo_dump(preferences, mocker):
|
|||
}
|
||||
},
|
||||
'metadata': {
|
||||
'private': preferences['instance__nodeinfo_private'],
|
||||
'shortDescription': preferences['instance__short_description'],
|
||||
'longDescription': preferences['instance__long_description'],
|
||||
'nodeName': preferences['instance__name'],
|
||||
|
@ -92,6 +93,7 @@ def test_nodeinfo_dump_stats_disabled(preferences, mocker):
|
|||
}
|
||||
},
|
||||
'metadata': {
|
||||
'private': preferences['instance__nodeinfo_private'],
|
||||
'shortDescription': preferences['instance__short_description'],
|
||||
'longDescription': preferences['instance__long_description'],
|
||||
'nodeName': preferences['instance__name'],
|
||||
|
|
|
@ -1,5 +1,16 @@
|
|||
import pytest
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from funkwhale_api.instance import views
|
||||
|
||||
|
||||
@pytest.mark.parametrize('view,permissions', [
|
||||
(views.AdminSettings, ['settings']),
|
||||
])
|
||||
def test_permissions(assert_user_permission, view, permissions):
|
||||
assert_user_permission(view, permissions)
|
||||
|
||||
|
||||
def test_nodeinfo_endpoint(db, api_client, mocker):
|
||||
payload = {
|
||||
|
@ -21,3 +32,32 @@ def test_nodeinfo_endpoint_disabled(db, api_client, preferences):
|
|||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_settings_only_list_public_settings(db, api_client, preferences):
|
||||
url = reverse('api:v1:instance:settings')
|
||||
response = api_client.get(url)
|
||||
|
||||
for conf in response.data:
|
||||
p = preferences.model.objects.get(
|
||||
section=conf['section'], name=conf['name'])
|
||||
assert p.preference.show_in_api is True
|
||||
|
||||
|
||||
def test_admin_settings_restrict_access(db, logged_in_api_client, preferences):
|
||||
url = reverse('api:v1:instance:admin-settings-list')
|
||||
response = logged_in_api_client.get(url)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_admin_settings_correct_permission(
|
||||
db, logged_in_api_client, preferences):
|
||||
user = logged_in_api_client.user
|
||||
user.permission_settings = True
|
||||
user.save()
|
||||
url = reverse('api:v1:instance:admin-settings-list')
|
||||
response = logged_in_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(response.data) == len(preferences.all())
|
||||
|
|
Binary file not shown.
|
@ -0,0 +1,45 @@
|
|||
from funkwhale_api.music.management.commands import fix_track_files
|
||||
|
||||
|
||||
def test_fix_track_files_bitrate_length(factories, mocker):
|
||||
tf1 = factories['music.TrackFile'](bitrate=1, duration=2)
|
||||
tf2 = factories['music.TrackFile'](bitrate=None, duration=None)
|
||||
c = fix_track_files.Command()
|
||||
|
||||
mocker.patch(
|
||||
'funkwhale_api.music.utils.get_audio_file_data',
|
||||
return_value={'bitrate': 42, 'length': 43})
|
||||
|
||||
c.fix_file_data(dry_run=False)
|
||||
|
||||
tf1.refresh_from_db()
|
||||
tf2.refresh_from_db()
|
||||
|
||||
# not updated
|
||||
assert tf1.bitrate == 1
|
||||
assert tf1.duration == 2
|
||||
|
||||
# updated
|
||||
assert tf2.bitrate == 42
|
||||
assert tf2.duration == 43
|
||||
|
||||
|
||||
def test_fix_track_files_size(factories, mocker):
|
||||
tf1 = factories['music.TrackFile'](size=1)
|
||||
tf2 = factories['music.TrackFile'](size=None)
|
||||
c = fix_track_files.Command()
|
||||
|
||||
mocker.patch(
|
||||
'funkwhale_api.music.models.TrackFile.get_file_size',
|
||||
return_value=2)
|
||||
|
||||
c.fix_file_size(dry_run=False)
|
||||
|
||||
tf1.refresh_from_db()
|
||||
tf2.refresh_from_db()
|
||||
|
||||
# not updated
|
||||
assert tf1.size == 1
|
||||
|
||||
# updated
|
||||
assert tf2.size == 2
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from django.urls import reverse
|
||||
|
@ -7,6 +8,8 @@ from funkwhale_api.federation import actors
|
|||
from funkwhale_api.federation import serializers as federation_serializers
|
||||
from funkwhale_api.music import tasks
|
||||
|
||||
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def test_create_import_can_bind_to_request(
|
||||
artists, albums, mocker, factories, superuser_api_client):
|
||||
|
@ -40,11 +43,20 @@ def test_create_import_can_bind_to_request(
|
|||
assert batch.import_request == request
|
||||
|
||||
|
||||
def test_import_job_from_federation_no_musicbrainz(factories):
|
||||
def test_import_job_from_federation_no_musicbrainz(factories, mocker):
|
||||
mocker.patch(
|
||||
'funkwhale_api.music.utils.get_audio_file_data',
|
||||
return_value={'bitrate': 24, 'length': 666})
|
||||
mocker.patch(
|
||||
'funkwhale_api.music.models.TrackFile.get_file_size',
|
||||
return_value=42)
|
||||
lt = factories['federation.LibraryTrack'](
|
||||
artist_name='Hello',
|
||||
album_title='World',
|
||||
title='Ping',
|
||||
metadata__length=42,
|
||||
metadata__bitrate=43,
|
||||
metadata__size=44,
|
||||
)
|
||||
job = factories['music.ImportJob'](
|
||||
federation=True,
|
||||
|
@ -56,6 +68,9 @@ def test_import_job_from_federation_no_musicbrainz(factories):
|
|||
|
||||
tf = job.track_file
|
||||
assert tf.mimetype == lt.audio_mimetype
|
||||
assert tf.duration == 42
|
||||
assert tf.bitrate == 43
|
||||
assert tf.size == 44
|
||||
assert tf.library_track == job.library_track
|
||||
assert tf.track.title == 'Ping'
|
||||
assert tf.track.artist.name == 'Hello'
|
||||
|
@ -234,13 +249,13 @@ def test_import_batch_notifies_followers(
|
|||
|
||||
|
||||
def test__do_import_in_place_mbid(factories, tmpfile):
|
||||
path = '/test.ogg'
|
||||
path = os.path.join(DATA_DIR, 'test.ogg')
|
||||
job = factories['music.ImportJob'](
|
||||
in_place=True, source='file:///test.ogg')
|
||||
in_place=True, source='file://{}'.format(path))
|
||||
|
||||
track = factories['music.Track'](mbid=job.mbid)
|
||||
tf = tasks._do_import(job, use_acoustid=False)
|
||||
|
||||
assert bool(tf.audio_file) is False
|
||||
assert tf.source == 'file:///test.ogg'
|
||||
assert tf.source == 'file://{}'.format(path)
|
||||
assert tf.mimetype == 'audio/ogg'
|
||||
|
|
|
@ -40,3 +40,20 @@ def test_can_get_metadata_from_id3_mp3_file(field, value):
|
|||
data = metadata.Metadata(path)
|
||||
|
||||
assert data.get(field) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize('field,value', [
|
||||
('title', '999,999'),
|
||||
('artist', 'Nine Inch Nails'),
|
||||
('album', 'The Slip'),
|
||||
('date', datetime.date(2008, 5, 5)),
|
||||
('track_number', 1),
|
||||
('musicbrainz_albumid', uuid.UUID('12b57d46-a192-499e-a91f-7da66790a1c1')),
|
||||
('musicbrainz_recordingid', uuid.UUID('30f3f33e-8d0c-4e69-8539-cbd701d18f28')),
|
||||
('musicbrainz_artistid', uuid.UUID('b7ffd2af-418f-4be2-bdd1-22f8b48613da')),
|
||||
])
|
||||
def test_can_get_metadata_from_flac_file(field, value):
|
||||
path = os.path.join(DATA_DIR, 'sample.flac')
|
||||
data = metadata.Metadata(path)
|
||||
|
||||
assert data.get(field) == value
|
||||
|
|
|
@ -77,3 +77,36 @@ def test_audio_track_mime_type(extention, mimetype, factories):
|
|||
tf = factories['music.TrackFile'](audio_file__from_path=path)
|
||||
|
||||
assert tf.mimetype == mimetype
|
||||
|
||||
|
||||
def test_track_file_file_name(factories):
|
||||
name = 'test.mp3'
|
||||
path = os.path.join(DATA_DIR, name)
|
||||
tf = factories['music.TrackFile'](audio_file__from_path=path)
|
||||
|
||||
assert tf.filename == tf.track.full_name + '.mp3'
|
||||
|
||||
|
||||
def test_track_get_file_size(factories):
|
||||
name = 'test.mp3'
|
||||
path = os.path.join(DATA_DIR, name)
|
||||
tf = factories['music.TrackFile'](audio_file__from_path=path)
|
||||
|
||||
assert tf.get_file_size() == 297745
|
||||
|
||||
|
||||
def test_track_get_file_size_federation(factories):
|
||||
tf = factories['music.TrackFile'](
|
||||
federation=True,
|
||||
library_track__with_audio_file=True)
|
||||
|
||||
assert tf.get_file_size() == tf.library_track.audio_file.size
|
||||
|
||||
|
||||
def test_track_get_file_size_in_place(factories):
|
||||
name = 'test.mp3'
|
||||
path = os.path.join(DATA_DIR, name)
|
||||
tf = factories['music.TrackFile'](
|
||||
in_place=True, source='file://{}'.format(path))
|
||||
|
||||
assert tf.get_file_size() == 297745
|
||||
|
|
|
@ -62,6 +62,9 @@ def test_import_job_can_run_with_file_and_acoustid(
|
|||
'score': 0.860825}],
|
||||
'status': 'ok'
|
||||
}
|
||||
mocker.patch(
|
||||
'funkwhale_api.music.utils.get_audio_file_data',
|
||||
return_value={'bitrate': 42, 'length': 43})
|
||||
mocker.patch(
|
||||
'funkwhale_api.musicbrainz.api.artists.get',
|
||||
return_value=artists['get']['adhesive_wombat'])
|
||||
|
@ -82,7 +85,9 @@ def test_import_job_can_run_with_file_and_acoustid(
|
|||
|
||||
with open(path, 'rb') as f:
|
||||
assert track_file.audio_file.read() == f.read()
|
||||
assert track_file.duration == 268
|
||||
assert track_file.bitrate == 42
|
||||
assert track_file.duration == 43
|
||||
assert track_file.size == os.path.getsize(path)
|
||||
# audio file is deleted from import job once persisted to audio file
|
||||
assert not job.audio_file
|
||||
assert job.status == 'finished'
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import os
|
||||
import pytest
|
||||
|
||||
from funkwhale_api.music import utils
|
||||
|
||||
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def test_guess_mimetype_try_using_extension(factories, mocker):
|
||||
mocker.patch(
|
||||
|
@ -17,3 +22,16 @@ def test_guess_mimetype_try_using_extension_if_fail(factories, mocker):
|
|||
audio_file__filename='test.mp3')
|
||||
|
||||
assert utils.guess_mimetype(f.audio_file) == 'audio/mpeg'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name, expected', [
|
||||
('sample.flac', {'bitrate': 1608000, 'length': 0.001}),
|
||||
('test.mp3', {'bitrate': 8000, 'length': 267.70285714285717}),
|
||||
('test.ogg', {'bitrate': 128000, 'length': 229.18304166666667}),
|
||||
])
|
||||
def test_get_audio_file_data(name, expected):
|
||||
path = os.path.join(DATA_DIR, name)
|
||||
with open(path, 'rb') as f:
|
||||
result = utils.get_audio_file_data(f)
|
||||
|
||||
assert result == expected
|
||||
|
|
|
@ -8,6 +8,14 @@ from funkwhale_api.music import views
|
|||
from funkwhale_api.federation import actors
|
||||
|
||||
|
||||
@pytest.mark.parametrize('view,permissions', [
|
||||
(views.ImportBatchViewSet, ['library']),
|
||||
(views.ImportJobViewSet, ['library']),
|
||||
])
|
||||
def test_permissions(assert_user_permission, view, permissions):
|
||||
assert_user_permission(view, permissions)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('param,expected', [
|
||||
('true', 'full'),
|
||||
('false', 'empty'),
|
||||
|
@ -104,6 +112,24 @@ def test_serve_file_in_place(
|
|||
assert response[headers[proxy]] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('proxy,serve_path,expected', [
|
||||
('apache2', '/host/music', '/host/music/hello/worldéà.mp3'),
|
||||
('apache2', '/app/music', '/app/music/hello/worldéà.mp3'),
|
||||
('nginx', '/host/music', '/_protected/music/hello/worldéà.mp3'),
|
||||
('nginx', '/app/music', '/_protected/music/hello/worldéà.mp3'),
|
||||
])
|
||||
def test_serve_file_in_place_utf8(
|
||||
proxy, serve_path, expected, factories, api_client, settings):
|
||||
settings.PROTECT_AUDIO_FILES = False
|
||||
settings.PROTECT_FILE_PATH = '/_protected/music'
|
||||
settings.REVERSE_PROXY_TYPE = proxy
|
||||
settings.MUSIC_DIRECTORY_PATH = '/app/music'
|
||||
settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path
|
||||
path = views.get_file_path('/app/music/hello/worldéà.mp3')
|
||||
|
||||
assert path == expected.encode('utf-8')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('proxy,serve_path,expected', [
|
||||
('apache2', '/host/music', '/host/media/tracks/hello/world.mp3'),
|
||||
# apache with container not supported yet
|
||||
|
|
|
@ -77,7 +77,8 @@ def test_get_album_serializer(factories):
|
|||
artist = factories['music.Artist']()
|
||||
album = factories['music.Album'](artist=artist)
|
||||
track = factories['music.Track'](album=album)
|
||||
tf = factories['music.TrackFile'](track=track)
|
||||
tf = factories['music.TrackFile'](
|
||||
track=track, bitrate=42000, duration=43, size=44)
|
||||
|
||||
expected = {
|
||||
'id': album.pk,
|
||||
|
@ -98,7 +99,9 @@ def test_get_album_serializer(factories):
|
|||
'year': track.album.release_date.year,
|
||||
'contentType': tf.mimetype,
|
||||
'suffix': tf.extension or '',
|
||||
'duration': tf.duration or 0,
|
||||
'bitrate': 42,
|
||||
'duration': 43,
|
||||
'size': 44,
|
||||
'created': track.creation_date,
|
||||
'albumId': album.pk,
|
||||
'artistId': artist.pk,
|
||||
|
@ -177,7 +180,8 @@ def test_playlist_detail_serializer(factories):
|
|||
|
||||
def test_directory_serializer_artist(factories):
|
||||
track = factories['music.Track']()
|
||||
tf = factories['music.TrackFile'](track=track)
|
||||
tf = factories['music.TrackFile'](
|
||||
track=track, bitrate=42000, duration=43, size=44)
|
||||
album = track.album
|
||||
artist = track.artist
|
||||
|
||||
|
@ -195,7 +199,9 @@ def test_directory_serializer_artist(factories):
|
|||
'year': track.album.release_date.year,
|
||||
'contentType': tf.mimetype,
|
||||
'suffix': tf.extension or '',
|
||||
'duration': tf.duration or 0,
|
||||
'bitrate': 42,
|
||||
'duration': 43,
|
||||
'size': 44,
|
||||
'created': track.creation_date,
|
||||
'albumId': album.pk,
|
||||
'artistId': artist.pk,
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
import pytest
|
||||
|
||||
from funkwhale_api.users import models
|
||||
|
||||
|
||||
def test__str__(factories):
|
||||
user = factories['users.User'](username='hello')
|
||||
|
@ -16,3 +20,33 @@ def test_changing_password_updates_subsonic_api_token(factories):
|
|||
|
||||
assert user.subsonic_api_token is not None
|
||||
assert user.subsonic_api_token != 'test'
|
||||
|
||||
|
||||
def test_get_permissions_superuser(factories):
|
||||
user = factories['users.User'](is_superuser=True)
|
||||
|
||||
perms = user.get_permissions()
|
||||
for p in models.PERMISSIONS:
|
||||
assert perms[p] is True
|
||||
|
||||
|
||||
def test_get_permissions_regular(factories):
|
||||
user = factories['users.User'](permission_library=True)
|
||||
|
||||
perms = user.get_permissions()
|
||||
for p in models.PERMISSIONS:
|
||||
if p == 'library':
|
||||
assert perms[p] is True
|
||||
else:
|
||||
assert perms[p] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize('args,perms,expected', [
|
||||
({'is_superuser': True}, ['federation', 'library'], True),
|
||||
({'is_superuser': False}, ['federation'], False),
|
||||
({'permission_library': True}, ['library'], True),
|
||||
({'permission_library': True}, ['library', 'federation'], False),
|
||||
])
|
||||
def test_has_permissions(args, perms, expected, factories):
|
||||
user = factories['users.User'](**args)
|
||||
assert user.has_permissions(*perms) is expected
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import pytest
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from funkwhale_api.users import permissions
|
||||
|
||||
|
||||
def test_has_user_permission_no_user(api_request):
|
||||
view = APIView.as_view()
|
||||
permission = permissions.HasUserPermission()
|
||||
request = api_request.get('/')
|
||||
assert permission.has_permission(request, view) is False
|
||||
|
||||
|
||||
def test_has_user_permission_anonymous(anonymous_user, api_request):
|
||||
view = APIView.as_view()
|
||||
permission = permissions.HasUserPermission()
|
||||
request = api_request.get('/')
|
||||
setattr(request, 'user', anonymous_user)
|
||||
assert permission.has_permission(request, view) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', [True, False])
|
||||
def test_has_user_permission_logged_in_single(value, factories, api_request):
|
||||
user = factories['users.User'](permission_federation=value)
|
||||
|
||||
class View(APIView):
|
||||
required_permissions = ['federation']
|
||||
view = View()
|
||||
permission = permissions.HasUserPermission()
|
||||
request = api_request.get('/')
|
||||
setattr(request, 'user', user)
|
||||
result = permission.has_permission(request, view)
|
||||
assert result == user.has_permissions('federation') == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize('federation,library,expected', [
|
||||
(True, False, False),
|
||||
(False, True, False),
|
||||
(False, False, False),
|
||||
(True, True, True),
|
||||
])
|
||||
def test_has_user_permission_logged_in_single(
|
||||
federation, library, expected, factories, api_request):
|
||||
user = factories['users.User'](
|
||||
permission_federation=federation,
|
||||
permission_library=library,
|
||||
)
|
||||
|
||||
class View(APIView):
|
||||
required_permissions = ['federation', 'library']
|
||||
view = View()
|
||||
permission = permissions.HasUserPermission()
|
||||
request = api_request.get('/')
|
||||
setattr(request, 'user', user)
|
||||
result = permission.has_permission(request, view)
|
||||
assert result == user.has_permissions('federation', 'library') == expected
|
|
@ -53,33 +53,24 @@ def test_can_disable_registration_view(preferences, client, db):
|
|||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_can_fetch_data_from_api(client, factories):
|
||||
def test_can_fetch_data_from_api(api_client, factories):
|
||||
url = reverse('api:v1:users:users-me')
|
||||
response = client.get(url)
|
||||
response = api_client.get(url)
|
||||
# login required
|
||||
assert response.status_code == 401
|
||||
|
||||
user = factories['users.User'](
|
||||
is_staff=True,
|
||||
perms=[
|
||||
'music.add_importbatch',
|
||||
'dynamic_preferences.change_globalpreferencemodel',
|
||||
]
|
||||
permission_library=True
|
||||
)
|
||||
assert user.has_perm('music.add_importbatch')
|
||||
client.login(username=user.username, password='test')
|
||||
response = client.get(url)
|
||||
api_client.login(username=user.username, password='test')
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
payload = json.loads(response.content.decode('utf-8'))
|
||||
|
||||
assert payload['username'] == user.username
|
||||
assert payload['is_staff'] == user.is_staff
|
||||
assert payload['is_superuser'] == user.is_superuser
|
||||
assert payload['email'] == user.email
|
||||
assert payload['name'] == user.name
|
||||
assert payload['permissions']['import.launch']['status']
|
||||
assert payload['permissions']['settings.change']['status']
|
||||
assert response.data['username'] == user.username
|
||||
assert response.data['is_staff'] == user.is_staff
|
||||
assert response.data['is_superuser'] == user.is_superuser
|
||||
assert response.data['email'] == user.email
|
||||
assert response.data['name'] == user.name
|
||||
assert response.data['permissions'] == user.get_permissions()
|
||||
|
||||
|
||||
def test_can_get_token_via_api(client, factories):
|
||||
|
@ -202,6 +193,8 @@ def test_user_can_get_new_subsonic_token(logged_in_api_client):
|
|||
assert response.data == {
|
||||
'subsonic_api_token': 'test'
|
||||
}
|
||||
|
||||
|
||||
def test_user_can_request_new_subsonic_token(logged_in_api_client):
|
||||
user = logged_in_api_client.user
|
||||
user.subsonic_api_token = 'test'
|
||||
|
|
|
@ -27,15 +27,24 @@ Those settings are stored in database and do not require a restart of your
|
|||
instance after modification. They typically relate to higher level configuration,
|
||||
such your instance description, signup policy and so on.
|
||||
|
||||
There is no polished interface for those settings, yet, but you can view update
|
||||
them using the administration interface provided by Django (the framework funkwhale is built on).
|
||||
|
||||
The URL should be ``/api/admin/dynamic_preferences/globalpreferencemodel/`` (prepend your domain in front of it, of course).
|
||||
You can edit those settings directly from the web application, assuming
|
||||
you have the required permissions. The URL is ``/manage/settings``, and
|
||||
you will also find a link to this page in the sidebar.
|
||||
|
||||
If you plan to use acoustid and external imports
|
||||
(e.g. with the youtube backends), you should edit the corresponding
|
||||
settings in this interface.
|
||||
|
||||
.. note::
|
||||
|
||||
If you have any issue with the web application, a management interface is also
|
||||
available for those settings from Django's administration interface. It's
|
||||
less user friendly, though, and we recommend you use the web app interface
|
||||
whenever possible.
|
||||
|
||||
The URL should be ``/api/admin/dynamic_preferences/globalpreferencemodel/`` (prepend your domain in front of it, of course).
|
||||
|
||||
|
||||
Configuration reference
|
||||
-----------------------
|
||||
|
||||
|
@ -108,3 +117,28 @@ Then, the value of :ref:`setting-MUSIC_DIRECTORY_SERVE_PATH` should be
|
|||
On non-docker setup, you don't need to configure this setting.
|
||||
|
||||
.. note:: This path should not include any trailing slash
|
||||
|
||||
User permissions
|
||||
----------------
|
||||
|
||||
Funkwhale's permission model works as follows:
|
||||
|
||||
- Anonymous users cannot do anything unless configured specifically
|
||||
- Logged-in users can use the application, but cannot do things that affect
|
||||
the whole instance
|
||||
- Superusers can do anything
|
||||
|
||||
To make things more granular and allow some delegation of responsability,
|
||||
superusers can grant specific permissions to specific users. Available
|
||||
permissions are:
|
||||
|
||||
- **Manage instance-level settings**: users with this permission can edit instance
|
||||
settings as described in :ref:`instance-settings`
|
||||
- **Manage library**: users with this permission can import new music in the
|
||||
instance
|
||||
- **Manage library federation**: users with this permission can ask to federate with
|
||||
other instances, and accept/deny federation requests from other intances
|
||||
|
||||
There is no dedicated interface to manage users permissions, but superusers
|
||||
can login on the Django's admin at ``/api/admin/`` and grant permissions
|
||||
to users at ``/api/admin/users/user/``.
|
||||
|
|
|
@ -5,6 +5,7 @@ export default {
|
|||
],
|
||||
formatsMap: {
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/mpeg': 'mp3'
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/x-flac': 'flac'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,12 @@
|
|||
<p v-if="!instance.short_description.value && !instance.long_description.value">
|
||||
{{ $t('Unfortunately, owners of this instance did not yet take the time to complete this page.') }}
|
||||
</p>
|
||||
<router-link
|
||||
class="ui button"
|
||||
v-if="$store.state.auth.availablePermissions['settings']"
|
||||
:to="{path: '/manage/settings', hash: 'instance'}">
|
||||
<i class="pencil icon"></i>{{ $t('Edit instance info') }}
|
||||
</router-link>
|
||||
<div
|
||||
v-if="instance.short_description.value"
|
||||
class="ui middle aligned stackable text container">
|
||||
|
|
|
@ -60,7 +60,7 @@
|
|||
<div class="menu">
|
||||
<router-link
|
||||
class="item"
|
||||
v-if="$store.state.auth.availablePermissions['import.launch']"
|
||||
v-if="$store.state.auth.availablePermissions['library']"
|
||||
:to="{name: 'library.requests', query: {status: 'pending' }}">
|
||||
<i class="download icon"></i>{{ $t('Import requests') }}
|
||||
<div
|
||||
|
@ -70,7 +70,7 @@
|
|||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
v-if="$store.state.auth.availablePermissions['federation.manage']"
|
||||
v-if="$store.state.auth.availablePermissions['federation']"
|
||||
:to="{path: '/manage/federation/libraries'}">
|
||||
<i class="sitemap icon"></i>{{ $t('Federation') }}
|
||||
<div
|
||||
|
@ -78,6 +78,12 @@
|
|||
:title="$t('Pending follow requests')">
|
||||
{{ notifications.federation }}</div>
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
v-if="$store.state.auth.availablePermissions['settings']"
|
||||
:to="{path: '/manage/settings'}">
|
||||
<i class="settings icon"></i>{{ $t('Settings') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -186,8 +192,8 @@ export default {
|
|||
}),
|
||||
showAdmin () {
|
||||
let adminPermissions = [
|
||||
this.$store.state.auth.availablePermissions['federation.manage'],
|
||||
this.$store.state.auth.availablePermissions['import.launch']
|
||||
this.$store.state.auth.availablePermissions['federation'],
|
||||
this.$store.state.auth.availablePermissions['library']
|
||||
]
|
||||
return adminPermissions.filter(e => {
|
||||
return e
|
||||
|
@ -203,7 +209,7 @@ export default {
|
|||
this.fetchFederationImportRequestsCount()
|
||||
},
|
||||
fetchFederationNotificationsCount () {
|
||||
if (!this.$store.state.auth.availablePermissions['federation.manage']) {
|
||||
if (!this.$store.state.auth.availablePermissions['federation']) {
|
||||
return
|
||||
}
|
||||
let self = this
|
||||
|
@ -212,12 +218,11 @@ export default {
|
|||
})
|
||||
},
|
||||
fetchFederationImportRequestsCount () {
|
||||
if (!this.$store.state.auth.availablePermissions['import.launch']) {
|
||||
if (!this.$store.state.auth.availablePermissions['library']) {
|
||||
return
|
||||
}
|
||||
let self = this
|
||||
axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => {
|
||||
console.log('YOLo')
|
||||
self.notifications.importRequests = response.data.count
|
||||
})
|
||||
},
|
||||
|
@ -256,7 +261,6 @@ export default {
|
|||
},
|
||||
'$store.state.availablePermissions': {
|
||||
handler () {
|
||||
console.log('YOLO')
|
||||
this.fetchNotificationsCount()
|
||||
},
|
||||
deep: true
|
||||
|
|
|
@ -0,0 +1,120 @@
|
|||
<template>
|
||||
<form :id="group.id" class="ui form" @submit.prevent="save">
|
||||
<div class="ui divider" />
|
||||
<h3 class="ui header">{{ group.label }}</h3>
|
||||
<div v-if="errors.length > 0" class="ui negative message">
|
||||
<div class="header">{{ $t('Error while saving settings') }}</div>
|
||||
<ul class="list">
|
||||
<li v-for="error in errors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="result" class="ui positive message">
|
||||
{{ $t('Settings updated successfully.') }}
|
||||
</div>
|
||||
<p v-if="group.help">{{ group.help }}</p>
|
||||
<div v-for="setting in settings" class="ui field">
|
||||
<template v-if="setting.field.widget.class !== 'CheckboxInput'">
|
||||
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
|
||||
<p v-if="setting.help_text">{{ setting.help_text }}</p>
|
||||
</template>
|
||||
<input
|
||||
:id="setting.identifier"
|
||||
v-if="setting.field.widget.class === 'PasswordInput'"
|
||||
type="password"
|
||||
class="ui input"
|
||||
v-model="values[setting.identifier]" />
|
||||
<input
|
||||
:id="setting.identifier"
|
||||
v-if="setting.field.widget.class === 'TextInput'"
|
||||
type="text"
|
||||
class="ui input"
|
||||
v-model="values[setting.identifier]" />
|
||||
<input
|
||||
:id="setting.identifier"
|
||||
v-if="setting.field.class === 'IntegerField'"
|
||||
type="number"
|
||||
class="ui input"
|
||||
v-model.number="values[setting.identifier]" />
|
||||
<textarea
|
||||
:id="setting.identifier"
|
||||
v-else-if="setting.field.widget.class === 'Textarea'"
|
||||
type="text"
|
||||
class="ui input"
|
||||
v-model="values[setting.identifier]" />
|
||||
<div v-else-if="setting.field.widget.class === 'CheckboxInput'" class="ui toggle checkbox">
|
||||
<input
|
||||
:id="setting.identifier"
|
||||
:name="setting.identifier"
|
||||
v-model="values[setting.identifier]"
|
||||
type="checkbox" />
|
||||
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
|
||||
<p v-if="setting.help_text">{{ setting.help_text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']">
|
||||
{{ $t('Save') }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
group: {type: Object, required: true},
|
||||
settingsData: {type: Array, required: true}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
values: {},
|
||||
result: null,
|
||||
errors: [],
|
||||
isLoading: false
|
||||
}
|
||||
},
|
||||
created () {
|
||||
let self = this
|
||||
this.settings.forEach(e => {
|
||||
self.values[e.identifier] = e.value
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
save () {
|
||||
let self = this
|
||||
this.isLoading = true
|
||||
self.errors = []
|
||||
self.result = null
|
||||
axios.post('instance/admin/settings/bulk/', self.values).then((response) => {
|
||||
self.result = true
|
||||
self.isLoading = false
|
||||
self.$store.dispatch('instance/fetchSettings')
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
settings () {
|
||||
let byIdentifier = {}
|
||||
this.settingsData.forEach(e => {
|
||||
byIdentifier[e.identifier] = e
|
||||
})
|
||||
return this.group.settings.map(e => {
|
||||
return byIdentifier[e]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
|
||||
.ui.checkbox p {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
|
@ -1,8 +1,9 @@
|
|||
<template>
|
||||
<div :class="['ui', {'tiny': discrete}, 'buttons']">
|
||||
<div :title="title" :class="['ui', {'tiny': discrete}, 'buttons']">
|
||||
<button
|
||||
:title="$t('Add to current queue')"
|
||||
@click="addNext(true)"
|
||||
:disabled="!playable"
|
||||
:class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']">
|
||||
<i class="ui play icon"></i>
|
||||
<template v-if="!discrete"><slot><i18next path="Play"/></slot></template>
|
||||
|
@ -10,9 +11,9 @@
|
|||
<div v-if="!discrete" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', 'icon', 'button']">
|
||||
<i class="dropdown icon"></i>
|
||||
<div class="menu">
|
||||
<div class="item"@click="add"><i class="plus icon"></i><i18next path="Add to queue"/></div>
|
||||
<div class="item"@click="addNext()"><i class="step forward icon"></i><i18next path="Play next"/></div>
|
||||
<div class="item"@click="addNext(true)"><i class="arrow down icon"></i><i18next path="Play now"/></div>
|
||||
<div class="item" :disabled="!playable" @click="add"><i class="plus icon"></i><i18next path="Add to queue"/></div>
|
||||
<div class="item" :disabled="!playable" @click="addNext()"><i class="step forward icon"></i><i18next path="Play next"/></div>
|
||||
<div class="item" :disabled="!playable" @click="addNext(true)"><i class="arrow down icon"></i><i18next path="Play now"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -45,9 +46,18 @@ export default {
|
|||
jQuery(this.$el).find('.ui.dropdown').dropdown()
|
||||
},
|
||||
computed: {
|
||||
title () {
|
||||
if (this.playable) {
|
||||
return this.$t('Play immediatly')
|
||||
} else {
|
||||
if (this.track) {
|
||||
return this.$t('This track is not imported and cannot be played')
|
||||
}
|
||||
}
|
||||
},
|
||||
playable () {
|
||||
if (this.track) {
|
||||
return true
|
||||
return this.track.files.length > 0
|
||||
} else if (this.tracks) {
|
||||
return this.tracks.length > 0
|
||||
} else if (this.playlist) {
|
||||
|
|
|
@ -55,7 +55,7 @@
|
|||
<p slot="modal-confirm"><i18next path="Deny"/></p>
|
||||
</dangerous-button>
|
||||
<dangerous-button v-if="follow.approved !== true" class="tiny basic labeled icon" color='green' @confirm="updateFollow(follow, true)">
|
||||
<i class="x icon"></i> <i18next path="Approve"/>
|
||||
<i class="check icon"></i> <i18next path="Approve"/>
|
||||
<p slot="modal-header"><i18next path="Approve access?"/></p>
|
||||
<p slot="modal-content">
|
||||
<i18next path="By confirming, {%0%}@{%1%} will be granted access to your library.">
|
||||
|
|
|
@ -13,10 +13,10 @@
|
|||
exact>
|
||||
<i18next path="Requests"/>
|
||||
</router-link>
|
||||
<router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>
|
||||
<router-link v-if="$store.state.auth.availablePermissions['library']" class="ui item" to="/library/import/launch" exact>
|
||||
<i18next path="Import"/>
|
||||
</router-link>
|
||||
<router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">
|
||||
<router-link v-if="$store.state.auth.availablePermissions['library']" class="ui item" to="/library/import/batches">
|
||||
<i18next path="Import batches"/>
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
|
@ -44,6 +44,46 @@
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="file" class="ui vertical stripe center aligned segment">
|
||||
<h2 class="ui header">{{ $t('Track information') }}</h2>
|
||||
<table class="ui very basic collapsing celled center aligned table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('Duration') }}
|
||||
</td>
|
||||
<td v-if="file.duration">
|
||||
{{ time.parse(file.duration) }}
|
||||
</td>
|
||||
<td v-else>
|
||||
{{ $t('N/A') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('Size') }}
|
||||
</td>
|
||||
<td v-if="file.size">
|
||||
{{ file.size | humanSize }}
|
||||
</td>
|
||||
<td v-else>
|
||||
{{ $t('N/A') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('Bitrate') }}
|
||||
</td>
|
||||
<td v-if="file.bitrate">
|
||||
{{ file.bitrate | humanSize }}/s
|
||||
</td>
|
||||
<td v-else>
|
||||
{{ $t('N/A') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="ui vertical stripe center aligned segment">
|
||||
<h2><i18next path="Lyrics"/></h2>
|
||||
<div v-if="isLoadingLyrics" class="ui vertical segment">
|
||||
|
@ -64,6 +104,8 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import time from '@/utils/time'
|
||||
import axios from 'axios'
|
||||
import url from '@/utils/url'
|
||||
import logger from '@/logging'
|
||||
|
@ -83,6 +125,7 @@ export default {
|
|||
},
|
||||
data () {
|
||||
return {
|
||||
time,
|
||||
isLoadingTrack: true,
|
||||
isLoadingLyrics: true,
|
||||
track: null,
|
||||
|
@ -134,6 +177,9 @@ export default {
|
|||
return u
|
||||
}
|
||||
},
|
||||
file () {
|
||||
return this.track.files[0]
|
||||
},
|
||||
lyricsSearchUrl () {
|
||||
let base = 'http://lyrics.wikia.com/wiki/Special:Search?query='
|
||||
let query = this.track.artist.name + ' ' + this.track.title
|
||||
|
@ -159,5 +205,8 @@ export default {
|
|||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
.table.center.aligned {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -7,9 +7,7 @@
|
|||
<div class="description">
|
||||
<template v-if="track">
|
||||
<h4 class="ui header">{{ $t('Current track') }}</h4>
|
||||
<div>
|
||||
{{ $t('"{%title%}" by {%artist%}', { title: track.title, artist: track.artist.name }) }}
|
||||
</div>
|
||||
<div v-html='trackDisplay'></div>
|
||||
<div class="ui divider"></div>
|
||||
</template>
|
||||
|
||||
|
@ -112,6 +110,12 @@ export default {
|
|||
let p = _.sortBy(this.playlists, [(e) => { return e.modification_date }])
|
||||
p.reverse()
|
||||
return p
|
||||
},
|
||||
trackDisplay () {
|
||||
return this.$t('"{%title%}" by {%artist%}', {
|
||||
title: this.track.title,
|
||||
artist: this.track.artist.name }
|
||||
)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
</span>
|
||||
<button
|
||||
@click="createImport"
|
||||
v-if="request.status === 'pending' && importAction && $store.state.auth.availablePermissions['import.launch']"
|
||||
v-if="request.status === 'pending' && importAction && $store.state.auth.availablePermissions['library']"
|
||||
class="ui mini basic green right floated button">{{ $t('Create import') }}</button>
|
||||
|
||||
</div>
|
||||
|
|
|
@ -47,4 +47,23 @@ export function capitalize (str) {
|
|||
|
||||
Vue.filter('capitalize', capitalize)
|
||||
|
||||
export function humanSize (bytes) {
|
||||
let si = true
|
||||
var thresh = si ? 1000 : 1024
|
||||
if (Math.abs(bytes) < thresh) {
|
||||
return bytes + ' B'
|
||||
}
|
||||
var units = si
|
||||
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
|
||||
var u = -1
|
||||
do {
|
||||
bytes /= thresh
|
||||
++u
|
||||
} while (Math.abs(bytes) >= thresh && u < units.length - 1)
|
||||
return bytes.toFixed(1) + ' ' + units[u]
|
||||
}
|
||||
|
||||
Vue.filter('humanSize', humanSize)
|
||||
|
||||
export default {}
|
||||
|
|
|
@ -35,8 +35,26 @@ Vue.use(VueMasonryPlugin)
|
|||
Vue.use(VueLazyload)
|
||||
Vue.config.productionTip = false
|
||||
Vue.directive('title', {
|
||||
inserted: (el, binding) => { document.title = binding.value + ' - Funkwhale' },
|
||||
updated: (el, binding) => { document.title = binding.value + ' - Funkwhale' }
|
||||
inserted: (el, binding) => {
|
||||
let parts = []
|
||||
let instanceName = store.state.instance.settings.instance.name.value
|
||||
if (instanceName.length === 0) {
|
||||
instanceName = 'Funkwhale'
|
||||
}
|
||||
parts.unshift(instanceName)
|
||||
parts.unshift(binding.value)
|
||||
document.title = parts.join(' - ')
|
||||
},
|
||||
updated: (el, binding) => {
|
||||
let parts = []
|
||||
let instanceName = store.state.instance.settings.instance.name.value
|
||||
if (instanceName.length === 0) {
|
||||
instanceName = 'Funkwhale'
|
||||
}
|
||||
parts.unshift(instanceName)
|
||||
parts.unshift(binding.value)
|
||||
document.title = parts.join(' - ')
|
||||
}
|
||||
})
|
||||
|
||||
axios.defaults.baseURL = config.API_URL
|
||||
|
|
|
@ -28,6 +28,7 @@ import RequestsList from '@/components/requests/RequestsList'
|
|||
import PlaylistDetail from '@/views/playlists/Detail'
|
||||
import PlaylistList from '@/views/playlists/List'
|
||||
import Favorites from '@/components/favorites/List'
|
||||
import AdminSettings from '@/views/admin/Settings'
|
||||
import FederationBase from '@/views/federation/Base'
|
||||
import FederationScan from '@/views/federation/Scan'
|
||||
import FederationLibraryDetail from '@/views/federation/LibraryDetail'
|
||||
|
@ -117,6 +118,11 @@ export default new Router({
|
|||
defaultPaginateBy: route.query.paginateBy
|
||||
})
|
||||
},
|
||||
{
|
||||
path: '/manage/settings',
|
||||
name: 'manage.settings',
|
||||
component: AdminSettings
|
||||
},
|
||||
{
|
||||
path: '/manage/federation',
|
||||
component: FederationBase,
|
||||
|
|
|
@ -112,7 +112,7 @@ export default {
|
|||
dispatch('playlists/fetchOwn', null, {root: true})
|
||||
Object.keys(data.permissions).forEach(function (key) {
|
||||
// this makes it easier to check for permissions in templates
|
||||
commit('permission', {key, status: data.permissions[String(key)].status})
|
||||
commit('permission', {key, status: data.permissions[String(key)]})
|
||||
})
|
||||
return response.data
|
||||
}, (response) => {
|
||||
|
|
|
@ -0,0 +1,155 @@
|
|||
<template>
|
||||
<div class="main pusher" v-title="$t('Instance settings')">
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui text container">
|
||||
<div :class="['ui', {'loading': isLoading}, 'form']"></div>
|
||||
<div id="settings-grid" v-if="settingsData" class="ui grid">
|
||||
<div class="twelve wide stretched column">
|
||||
<settings-group
|
||||
:settings-data="settingsData"
|
||||
:group="group"
|
||||
:key="group.title"
|
||||
v-for="group in groups" />
|
||||
</div>
|
||||
<div class="four wide column">
|
||||
<div class="ui sticky vertical secondary menu">
|
||||
<div class="header item">{{ $t('Sections') }}</div>
|
||||
<a :class="['menu', {active: group.id === current}, 'item']"
|
||||
@click.prevent="scrollTo(group.id)"
|
||||
:href="'#' + group.id"
|
||||
v-for="group in groups">{{ group.label }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import $ from 'jquery'
|
||||
|
||||
import SettingsGroup from '@/components/admin/SettingsGroup'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SettingsGroup
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
settingsData: null,
|
||||
current: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
let self = this
|
||||
this.fetchSettings().then(r => {
|
||||
self.$nextTick(() => {
|
||||
if (self.$store.state.route.hash) {
|
||||
self.scrollTo(self.$store.state.route.hash.substr(1))
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
scrollTo (id) {
|
||||
console.log(id, 'hello')
|
||||
this.current = id
|
||||
document.getElementById(id).scrollIntoView()
|
||||
},
|
||||
fetchSettings () {
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
return axios.get('instance/admin/settings/').then((response) => {
|
||||
self.settingsData = response.data
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
groups () {
|
||||
return [
|
||||
{
|
||||
label: this.$t('Instance information'),
|
||||
id: 'instance',
|
||||
settings: [
|
||||
'instance__name',
|
||||
'instance__short_description',
|
||||
'instance__long_description'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: this.$t('Users'),
|
||||
id: 'users',
|
||||
settings: [
|
||||
'users__registration_enabled',
|
||||
'common__api_authentication_required'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: this.$t('Imports'),
|
||||
id: 'imports',
|
||||
settings: [
|
||||
'providers_youtube__api_key',
|
||||
'providers_acoustid__api_key'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: this.$t('Playlists'),
|
||||
id: 'playlists',
|
||||
settings: [
|
||||
'playlists__max_tracks'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: this.$t('Federation'),
|
||||
id: 'federation',
|
||||
settings: [
|
||||
'federation__enabled',
|
||||
'federation__music_needs_approval',
|
||||
'federation__collection_page_size',
|
||||
'federation__music_cache_duration',
|
||||
'federation__actor_fetch_delay'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: this.$t('Subsonic'),
|
||||
id: 'subsonic',
|
||||
settings: [
|
||||
'subsonic__enabled'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: this.$t('Statistics'),
|
||||
id: 'statistics',
|
||||
settings: [
|
||||
'instance__nodeinfo_enabled',
|
||||
'instance__nodeinfo_stats_enabled',
|
||||
'instance__nodeinfo_private'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: this.$t('Error reporting'),
|
||||
id: 'reporting',
|
||||
settings: [
|
||||
'raven__front_enabled',
|
||||
'raven__front_dsn'
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
settingsData () {
|
||||
let self = this
|
||||
this.$nextTick(() => {
|
||||
$(self.$el).find('.sticky').sticky({context: '#settings-grid'})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -76,7 +76,6 @@ export default {
|
|||
Pagination
|
||||
},
|
||||
data () {
|
||||
console.log('YOLO', this.$t)
|
||||
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
|
||||
return {
|
||||
isLoading: true,
|
||||
|
|
|
@ -164,9 +164,7 @@ describe('store/auth', () => {
|
|||
const profile = {
|
||||
username: 'bob',
|
||||
permissions: {
|
||||
admin: {
|
||||
status: true
|
||||
}
|
||||
admin: true
|
||||
}
|
||||
}
|
||||
moxios.stubRequest('users/users/me/', {
|
||||
|
|
Loading…
Reference in New Issue