Merge branch 'release/0.13'

This commit is contained in:
Eliot Berriot 2018-05-19 11:43:34 +02:00
commit 7b18e46fc9
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
80 changed files with 1623 additions and 183 deletions

View File

@ -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
View File

@ -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
...

View File

@ -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
}

View File

@ -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('.')])

View File

@ -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).'
)

View File

@ -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

View File

@ -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.

View File

@ -0,0 +1,2 @@
from . import django_permissions_to_user_permissions
from . import test

View File

@ -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})

View File

@ -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')

View File

@ -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.'
)

View File

@ -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

View File

@ -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)

View File

@ -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),

View File

@ -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',

View File

@ -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.'
)

View File

@ -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'),

View File

@ -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

View File

@ -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 = []

View File

@ -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']

View File

@ -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'

View File

@ -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)
)
)

View File

@ -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'
},
}
},
}

View File

@ -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'),
),
]

View File

@ -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)

View 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

View File

@ -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

View 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

View File

@ -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

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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')

View File

@ -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

View File

@ -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(

View File

@ -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',
)})
)

View File

@ -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.'
)

View File

@ -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'

View File

@ -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),
),
]

View File

@ -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):

View File

@ -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)

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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),

View File

@ -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

View File

@ -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'],

View File

@ -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())

BIN
api/tests/music/sample.flac Normal file

Binary file not shown.

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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/``.

View File

@ -5,6 +5,7 @@ export default {
],
formatsMap: {
'audio/ogg': 'ogg',
'audio/mpeg': 'mp3'
'audio/mpeg': 'mp3',
'audio/x-flac': 'flac'
}
}

View File

@ -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">

View File

@ -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

View File

@ -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>

View File

@ -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) {

View File

@ -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.">

View File

@ -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>

View File

@ -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>

View File

@ -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: {

View File

@ -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>

View File

@ -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 {}

View File

@ -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

View File

@ -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,

View File

@ -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) => {

View File

@ -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>

View File

@ -76,7 +76,6 @@ export default {
Pagination
},
data () {
console.log('YOLO', this.$t)
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
return {
isLoading: true,

View File

@ -164,9 +164,7 @@ describe('store/auth', () => {
const profile = {
username: 'bob',
permissions: {
admin: {
status: true
}
admin: true
}
}
moxios.stubRequest('users/users/me/', {