Merge branch 'release/0.14'
This commit is contained in:
commit
7d6e6c6a8e
1
.env.dev
1
.env.dev
|
@ -11,3 +11,4 @@ WEBPACK_DEVSERVER_PORT=8080
|
|||
MUSIC_DIRECTORY_PATH=/music
|
||||
BROWSABLE_API_ENABLED=True
|
||||
CACHEOPS_ENABLED=False
|
||||
FORWARDED_PROTO=http
|
||||
|
|
204
CHANGELOG
204
CHANGELOG
|
@ -10,6 +10,210 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.
|
|||
|
||||
.. towncrier
|
||||
|
||||
0.14 (2018-06-02)
|
||||
-----------------
|
||||
|
||||
Upgrade instructions are available at
|
||||
https://docs.funkwhale.audio/upgrading.html
|
||||
|
||||
Features:
|
||||
|
||||
- Admins can now configure default permissions that will be granted to all
|
||||
registered users (#236)
|
||||
- Files management interface for users with "library" permission (#223)
|
||||
- New action table component for quick and efficient batch actions (#228) This
|
||||
is implemented on the federated tracks pages, but will be included in other
|
||||
pages as well depending on the feedback.
|
||||
|
||||
|
||||
Enhancements:
|
||||
|
||||
- Added a new "upload" permission that allows user to launch import and view
|
||||
their own imports (#230)
|
||||
- Added Support for OggTheora in import.
|
||||
- Autoremove media files on model instance deletion (#241)
|
||||
- Can now import a whole remote library at once thanks to new Action Table
|
||||
component (#164)
|
||||
- Can now use album covers from flac/mp3 metadata and separate file in track
|
||||
directory (#219)
|
||||
- Implemented getCovertArt in Subsonic API to serve album covers (#258)
|
||||
- Implemented scrobble endpoint of subsonic API, listenings are now tracked
|
||||
correctly from third party apps that use this endpoint (#260)
|
||||
- Retructured music API to increase performance and remove useless endpoints
|
||||
(#224)
|
||||
|
||||
|
||||
Bugfixes:
|
||||
|
||||
- Consistent constraints/checks for URL size (#207)
|
||||
- Display proper total number of tracks on radio detail (#225)
|
||||
- Do not crash on flac import if musicbrainz tags are missing (#214)
|
||||
- Empty save button in radio builder (#226)
|
||||
- Ensure anonymous users can use the app if the instance is configured
|
||||
accordingly (#229)
|
||||
- Ensure inactive users cannot get auth tokens (#218) This was already the case
|
||||
bug we missed some checks
|
||||
- File-upload import now supports Flac files (#213)
|
||||
- File-upload importer should now work properly, assuming files are tagged
|
||||
(#106)
|
||||
- Fixed a few broken translations strings (#227)
|
||||
- Fixed broken ordering in front-end lists (#179)
|
||||
- Fixed ignored page_size paremeter on artist and favorites list (#240)
|
||||
- Read ID3Tag Tracknumber from TRCK (#220)
|
||||
- We now fetch album covers regardless of the import methods (#231)
|
||||
|
||||
Documentation:
|
||||
|
||||
- Added missing subsonic configuration block in deployment vhost files (#249)
|
||||
- Moved upgrade doc under install doc in TOC (#251)
|
||||
|
||||
|
||||
Other:
|
||||
|
||||
- Removed acoustid support, as the integration was buggy and error-prone (#106)
|
||||
|
||||
|
||||
Files management interface
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
This is the first bit of an ongoing work that will span several releases, to
|
||||
bring more powerful library management features to Funkwhale. This iteration
|
||||
includes a basic file management interface where users with the "library"
|
||||
permission can list and search available files, order them using
|
||||
various criterias (size, bitrate, duration...) and delete them.
|
||||
|
||||
New "upload" permission
|
||||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
This new permission is helpful if you want to give upload/import rights
|
||||
to some users, but don't want them to be able to manage the library as a whole:
|
||||
although there are no controls yet for managing library in the fron-end,
|
||||
subsequent release will introduce management interfaces for artists, files,
|
||||
etc.
|
||||
|
||||
Because of that, users with the "library" permission will have much more power,
|
||||
and will also be able to remove content from the platform. On the other hand,
|
||||
users with the "upload" permission will only have the ability to add new
|
||||
content.
|
||||
|
||||
Also, this release also includes a new feature called "default permissions":
|
||||
those are permissions that are granted to every users on the platform.
|
||||
On public/open instances, this will play well with the "upload" permission
|
||||
since everyone will be able to contribute to the instance library without
|
||||
an admin giving the permission to every single user.
|
||||
|
||||
Smarter album cover importer
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
In earlier versions, covers where only imported when launching a YouTube import.
|
||||
Starting from this release, covers will be imported regardless of the import mode
|
||||
(file upload, youtube-dl, CLI, in-place...). Funkwhale will look for covers
|
||||
in the following order:
|
||||
|
||||
1. In the imported file itself (FLAC/MP3 only)
|
||||
2. In a cover.jpg or cover.png in the file directory
|
||||
3. By fetching cover art from Musibrainz, assuming the file is tagged correctly
|
||||
|
||||
This will only work for newly imported tracks and albums though. In the future,
|
||||
we may offer an option to refetch album covers from the interface, but in the
|
||||
meantime, you can use the following snippet:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Store this in /tmp/update_albums.py
|
||||
from funkwhale_api.music.models import Album, TrackFile
|
||||
from funkwhale_api.music.tasks import update_album_cover
|
||||
|
||||
albums_without_covers = Album.objects.filter(cover='')
|
||||
total = albums_without_covers.count()
|
||||
print('Found {} albums without cover'.format(total))
|
||||
for i, album in enumerate(albums_without_covers.iterator()):
|
||||
print('[{}/{}] Fetching cover for {}...'.format(i+1, total, album.title))
|
||||
f = TrackFile.objects.filter(track__album=album).filter(source__startswith='file://').first()
|
||||
update_album_cover(album, track_file=f)
|
||||
|
||||
Then launch it::
|
||||
|
||||
# docker setups
|
||||
cat /tmp/update_albums.py | docker-compose run --rm api python manage.py shell -i python
|
||||
|
||||
# non-docker setups
|
||||
source /srv/funkwhale/load_env
|
||||
source /srv/funkwhale/virtualenv/bin/activate
|
||||
cat /tmp/update_albums.py | python manage.py shell -i python
|
||||
|
||||
# cleanup
|
||||
rm /tmp/update_albums.py
|
||||
|
||||
.. note::
|
||||
|
||||
Depending on your number of albums, the previous snippet may take some time
|
||||
to execute. You can interrupt it at any time using ctrl-c and relaunch it later,
|
||||
as it's idempotent.
|
||||
|
||||
Music API changes
|
||||
^^^^^^^^^^^^^^^^^
|
||||
|
||||
This release includes an API break. Even though the API is advertised
|
||||
as unstable, and not documented, here is a brief explanation of the change in
|
||||
case you are using the API in a client or in a script. Summary of the changes:
|
||||
|
||||
- ``/api/v1/artists`` does not includes a list of tracks anymore. It was to heavy
|
||||
to return all of this data all the time. You can get all tracks for an
|
||||
artist using ``/api/v1/tracks?artist=artist_id``
|
||||
- Additionally, ``/api/v1/tracks`` now support an ``album`` filter to filter
|
||||
tracks matching an album
|
||||
- ``/api/v1/artists/search``, ``/api/v1/albums/search`` and ``/api/v1/tracks/search``
|
||||
endpoints are removed. Use ``/api/v1/{artists|albums|tracks}/?q=yourquery``
|
||||
instead. It's also more powerful, since you can combine search with other
|
||||
filters and ordering options.
|
||||
- ``/api/v1/requests/import-requests/search`` endpoint is removed as well.
|
||||
Use ``/api/v1/requests/import-requests/?q=yourquery``
|
||||
instead. It's also more powerful, since you can combine search with other
|
||||
filters and ordering options.
|
||||
|
||||
Of course, the front-end was updated to work with the new API, so this should
|
||||
not impact end-users in any way, apart from slight performance gains.
|
||||
|
||||
.. note::
|
||||
|
||||
The API is still not stable and may evolve again in the future. API freeze
|
||||
will come at a later point.
|
||||
|
||||
Flac files imports via upload
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
You have nothing to do to benefit from this, however, since Flac files
|
||||
tend to be a lot bigger than other files, you may want to increase the
|
||||
``client_max_body_size`` value in your Nginx configuration if you plan
|
||||
to upload flac files.
|
||||
|
||||
Missing subsonic configuration bloc in vhost files
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Because of a missing bloc in the sample Nginx and Apache configurations,
|
||||
instances that were deployed after the 0.13 release are likely to be unable
|
||||
to answer to Subsonic clients (the missing bits were properly documented
|
||||
in the changelog).
|
||||
|
||||
Ensure you have the following snippets in your Nginx or Apache configuration
|
||||
if you plan to use the Subsonic API.
|
||||
|
||||
Nginx::
|
||||
|
||||
location /rest/ {
|
||||
include /etc/nginx/funkwhale_proxy.conf;
|
||||
proxy_pass http://funkwhale-api/api/subsonic/rest/;
|
||||
}
|
||||
|
||||
Apache2::
|
||||
|
||||
<Location "/rest">
|
||||
ProxyPass ${funkwhale-api}/api/subsonic/rest
|
||||
ProxyPassReverse ${funkwhale-api}/api/subsonic/rest
|
||||
</Location>
|
||||
|
||||
|
||||
0.13 (2018-05-19)
|
||||
-----------------
|
||||
|
||||
|
|
|
@ -38,6 +38,10 @@ v1_patterns += [
|
|||
include(
|
||||
('funkwhale_api.instance.urls', 'instance'),
|
||||
namespace='instance')),
|
||||
url(r'^manage/',
|
||||
include(
|
||||
('funkwhale_api.manage.urls', 'manage'),
|
||||
namespace='manage')),
|
||||
url(r'^federation/',
|
||||
include(
|
||||
('funkwhale_api.federation.api_urls', 'federation'),
|
||||
|
|
|
@ -97,6 +97,7 @@ THIRD_PARTY_APPS = (
|
|||
'dynamic_preferences',
|
||||
'django_filters',
|
||||
'cacheops',
|
||||
'django_cleanup',
|
||||
)
|
||||
|
||||
|
||||
|
@ -302,6 +303,9 @@ ROOT_URLCONF = 'config.urls'
|
|||
WSGI_APPLICATION = 'config.wsgi.application'
|
||||
ASGI_APPLICATION = "config.routing.application"
|
||||
|
||||
# This ensures that Django will be able to detect a secure connection
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
# AUTHENTICATION CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
|
@ -433,12 +437,6 @@ USE_X_FORWARDED_PORT = True
|
|||
REVERSE_PROXY_TYPE = env('REVERSE_PROXY_TYPE', default='nginx')
|
||||
assert REVERSE_PROXY_TYPE in ['apache2', 'nginx'], 'Unsupported REVERSE_PROXY_TYPE'
|
||||
|
||||
# Wether we should check user permission before serving audio files (meaning
|
||||
# return an obfuscated url)
|
||||
# This require a special configuration on the reverse proxy side
|
||||
# See https://wellfire.co/learn/nginx-django-x-accel-redirects/ for example
|
||||
PROTECT_AUDIO_FILES = env.bool('PROTECT_AUDIO_FILES', default=True)
|
||||
|
||||
# Which path will be used to process the internal redirection
|
||||
# **DO NOT** put a slash at the end
|
||||
PROTECT_FILES_PATH = env('PROTECT_FILES_PATH', default='/_protected')
|
||||
|
|
|
@ -76,3 +76,4 @@ LOGGING = {
|
|||
},
|
||||
},
|
||||
}
|
||||
CSRF_TRUSTED_ORIGINS = [o for o in ALLOWED_HOSTS]
|
||||
|
|
|
@ -22,10 +22,6 @@ from .common import * # noqa
|
|||
# Raises ImproperlyConfigured exception if DJANGO_SECRET_KEY not in os.environ
|
||||
SECRET_KEY = env("DJANGO_SECRET_KEY")
|
||||
|
||||
# This ensures that Django will be able to detect a secure connection
|
||||
# properly on Heroku.
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
# django-secure
|
||||
# ------------------------------------------------------------------------------
|
||||
# INSTALLED_APPS += ("djangosecure", )
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
__version__ = '0.13'
|
||||
__version__ = '0.14'
|
||||
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
|
||||
|
|
|
@ -3,4 +3,4 @@ from rest_framework.pagination import PageNumberPagination
|
|||
|
||||
class FunkwhalePagination(PageNumberPagination):
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 25
|
||||
max_page_size = 50
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
from django.conf import settings
|
||||
from django import forms
|
||||
|
||||
from dynamic_preferences import serializers
|
||||
from dynamic_preferences import types
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
|
||||
|
@ -10,3 +14,38 @@ class DefaultFromSettingMixin(object):
|
|||
def get(pref):
|
||||
manager = global_preferences_registry.manager()
|
||||
return manager[pref]
|
||||
|
||||
|
||||
class StringListSerializer(serializers.BaseSerializer):
|
||||
separator = ','
|
||||
sort = True
|
||||
|
||||
@classmethod
|
||||
def to_db(cls, value, **kwargs):
|
||||
if not value:
|
||||
return
|
||||
|
||||
if type(value) not in [list, tuple]:
|
||||
raise cls.exception(
|
||||
"Cannot serialize, value {} is not a list or a tuple".format(
|
||||
value))
|
||||
|
||||
if cls.sort:
|
||||
value = sorted(value)
|
||||
return cls.separator.join(value)
|
||||
|
||||
@classmethod
|
||||
def to_python(cls, value, **kwargs):
|
||||
if not value:
|
||||
return []
|
||||
return value.split(',')
|
||||
|
||||
|
||||
class StringListPreference(types.BasePreferenceType):
|
||||
serializer = StringListSerializer
|
||||
field_class = forms.MultipleChoiceField
|
||||
|
||||
def get_api_additional_data(self):
|
||||
d = super(StringListPreference, self).get_api_additional_data()
|
||||
d['choices'] = self.get('choices')
|
||||
return d
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
|
||||
class ActionSerializer(serializers.Serializer):
|
||||
"""
|
||||
A special serializer that can operate on a list of objects
|
||||
and apply actions on it.
|
||||
"""
|
||||
|
||||
action = serializers.CharField(required=True)
|
||||
objects = serializers.JSONField(required=True)
|
||||
filters = serializers.DictField(required=False)
|
||||
actions = None
|
||||
filterset_class = None
|
||||
# those are actions identifier where we don't want to allow the "all"
|
||||
# selector because it's to dangerous. Like object deletion.
|
||||
dangerous_actions = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.queryset = kwargs.pop('queryset')
|
||||
if self.actions is None:
|
||||
raise ValueError(
|
||||
'You must declare a list of actions on '
|
||||
'the serializer class')
|
||||
|
||||
for action in self.actions:
|
||||
handler_name = 'handle_{}'.format(action)
|
||||
assert hasattr(self, handler_name), (
|
||||
'{} miss a {} method'.format(
|
||||
self.__class__.__name__, handler_name)
|
||||
)
|
||||
super().__init__(self, *args, **kwargs)
|
||||
|
||||
def validate_action(self, value):
|
||||
if value not in self.actions:
|
||||
raise serializers.ValidationError(
|
||||
'{} is not a valid action. Pick one of {}.'.format(
|
||||
value, ', '.join(self.actions)
|
||||
)
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_objects(self, value):
|
||||
qs = None
|
||||
if value == 'all':
|
||||
return self.queryset.all().order_by('id')
|
||||
if type(value) in [list, tuple]:
|
||||
return self.queryset.filter(pk__in=value).order_by('id')
|
||||
|
||||
raise serializers.ValidationError(
|
||||
'{} is not a valid value for objects. You must provide either a '
|
||||
'list of identifiers or the string "all".'.format(value))
|
||||
|
||||
def validate(self, data):
|
||||
dangerous = data['action'] in self.dangerous_actions
|
||||
if dangerous and self.initial_data['objects'] == 'all':
|
||||
raise serializers.ValidationError(
|
||||
'This action is to dangerous to be applied to all objects')
|
||||
if self.filterset_class and 'filters' in data:
|
||||
qs_filterset = self.filterset_class(
|
||||
data['filters'], queryset=data['objects'])
|
||||
try:
|
||||
assert qs_filterset.form.is_valid()
|
||||
except (AssertionError, TypeError):
|
||||
raise serializers.ValidationError('Invalid filters')
|
||||
data['objects'] = qs_filterset.qs
|
||||
|
||||
data['count'] = data['objects'].count()
|
||||
if data['count'] < 1:
|
||||
raise serializers.ValidationError(
|
||||
'No object matching your request')
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
handler_name = 'handle_{}'.format(self.validated_data['action'])
|
||||
handler = getattr(self, handler_name)
|
||||
result = handler(self.validated_data['objects'])
|
||||
payload = {
|
||||
'updated': self.validated_data['count'],
|
||||
'action': self.validated_data['action'],
|
||||
'result': result,
|
||||
}
|
||||
return payload
|
|
@ -3,7 +3,6 @@ from django.conf import settings
|
|||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.activity import serializers as activity_serializers
|
||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||
from funkwhale_api.music.serializers import TrackActivitySerializer
|
||||
from funkwhale_api.users.serializers import UserActivitySerializer
|
||||
|
||||
|
@ -35,7 +34,6 @@ class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
|
|||
|
||||
|
||||
class UserTrackFavoriteSerializer(serializers.ModelSerializer):
|
||||
# track = TrackSerializerNested(read_only=True)
|
||||
class Meta:
|
||||
model = models.TrackFavorite
|
||||
fields = ('id', 'track', 'creation_date')
|
||||
|
|
|
@ -12,12 +12,6 @@ from . import models
|
|||
from . import serializers
|
||||
|
||||
|
||||
class CustomLimitPagination(pagination.PageNumberPagination):
|
||||
page_size = 100
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class TrackFavoriteViewSet(mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
|
@ -26,7 +20,6 @@ class TrackFavoriteViewSet(mixins.CreateModelMixin,
|
|||
serializer_class = serializers.UserTrackFavoriteSerializer
|
||||
queryset = (models.TrackFavorite.objects.all())
|
||||
permission_classes = [ConditionalAuthentication]
|
||||
pagination_class = CustomLimitPagination
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
|
|
|
@ -24,7 +24,7 @@ class LibraryFilter(django_filters.FilterSet):
|
|||
|
||||
class LibraryTrackFilter(django_filters.FilterSet):
|
||||
library = django_filters.CharFilter('library__uuid')
|
||||
imported = django_filters.CharFilter(method='filter_imported')
|
||||
status = django_filters.CharFilter(method='filter_status')
|
||||
q = fields.SearchFilter(search_fields=[
|
||||
'artist_name',
|
||||
'title',
|
||||
|
@ -32,11 +32,15 @@ class LibraryTrackFilter(django_filters.FilterSet):
|
|||
'library__actor__domain',
|
||||
])
|
||||
|
||||
def filter_imported(self, queryset, field_name, value):
|
||||
if value.lower() in ['true', '1', 'yes']:
|
||||
queryset = queryset.filter(local_track_file__isnull=False)
|
||||
elif value.lower() in ['false', '0', 'no']:
|
||||
queryset = queryset.filter(local_track_file__isnull=True)
|
||||
def filter_status(self, queryset, field_name, value):
|
||||
if value == 'imported':
|
||||
return queryset.filter(local_track_file__isnull=False)
|
||||
elif value == 'not_imported':
|
||||
return queryset.filter(
|
||||
local_track_file__isnull=True
|
||||
).exclude(import_jobs__status='pending')
|
||||
elif value == 'import_pending':
|
||||
return queryset.filter(import_jobs__status='pending')
|
||||
return queryset
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 2.0.4 on 2018-05-21 17:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('federation', '0005_auto_20180413_1723'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='library',
|
||||
name='url',
|
||||
field=models.URLField(max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='librarytrack',
|
||||
name='audio_url',
|
||||
field=models.URLField(max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='librarytrack',
|
||||
name='url',
|
||||
field=models.URLField(max_length=500, unique=True),
|
||||
),
|
||||
]
|
|
@ -139,7 +139,7 @@ class Library(models.Model):
|
|||
on_delete=models.CASCADE,
|
||||
related_name='library')
|
||||
uuid = models.UUIDField(default=uuid.uuid4)
|
||||
url = models.URLField()
|
||||
url = models.URLField(max_length=500)
|
||||
|
||||
# use this flag to disable federation with a library
|
||||
federation_enabled = models.BooleanField()
|
||||
|
@ -166,8 +166,8 @@ def get_file_path(instance, filename):
|
|||
|
||||
|
||||
class LibraryTrack(models.Model):
|
||||
url = models.URLField(unique=True)
|
||||
audio_url = models.URLField()
|
||||
url = models.URLField(unique=True, max_length=500)
|
||||
audio_url = models.URLField(max_length=500)
|
||||
audio_mimetype = models.CharField(max_length=200)
|
||||
audio_file = models.FileField(
|
||||
upload_to=get_file_path,
|
||||
|
|
|
@ -10,8 +10,11 @@ from rest_framework import serializers
|
|||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
from funkwhale_api.common import utils as funkwhale_utils
|
||||
|
||||
from funkwhale_api.common import serializers as common_serializers
|
||||
from funkwhale_api.music import models as music_models
|
||||
from funkwhale_api.music import tasks as music_tasks
|
||||
from . import activity
|
||||
from . import filters
|
||||
from . import models
|
||||
from . import utils
|
||||
|
||||
|
@ -26,16 +29,16 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
class ActorSerializer(serializers.Serializer):
|
||||
id = serializers.URLField()
|
||||
outbox = serializers.URLField()
|
||||
inbox = serializers.URLField()
|
||||
id = serializers.URLField(max_length=500)
|
||||
outbox = serializers.URLField(max_length=500)
|
||||
inbox = serializers.URLField(max_length=500)
|
||||
type = serializers.ChoiceField(choices=models.TYPE_CHOICES)
|
||||
preferredUsername = serializers.CharField()
|
||||
manuallyApprovesFollowers = serializers.NullBooleanField(required=False)
|
||||
name = serializers.CharField(required=False, max_length=200)
|
||||
summary = serializers.CharField(max_length=None, required=False)
|
||||
followers = serializers.URLField(required=False, allow_null=True)
|
||||
following = serializers.URLField(required=False, allow_null=True)
|
||||
followers = serializers.URLField(max_length=500, required=False, allow_null=True)
|
||||
following = serializers.URLField(max_length=500, required=False, allow_null=True)
|
||||
publicKey = serializers.JSONField(required=False)
|
||||
|
||||
def to_representation(self, instance):
|
||||
|
@ -224,7 +227,7 @@ class APILibraryFollowUpdateSerializer(serializers.Serializer):
|
|||
|
||||
|
||||
class APILibraryCreateSerializer(serializers.ModelSerializer):
|
||||
actor = serializers.URLField()
|
||||
actor = serializers.URLField(max_length=500)
|
||||
federation_enabled = serializers.BooleanField()
|
||||
uuid = serializers.UUIDField(read_only=True)
|
||||
|
||||
|
@ -293,6 +296,7 @@ class APILibraryCreateSerializer(serializers.ModelSerializer):
|
|||
|
||||
class APILibraryTrackSerializer(serializers.ModelSerializer):
|
||||
library = APILibrarySerializer()
|
||||
status = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.LibraryTrack
|
||||
|
@ -311,13 +315,25 @@ class APILibraryTrackSerializer(serializers.ModelSerializer):
|
|||
'title',
|
||||
'library',
|
||||
'local_track_file',
|
||||
'status',
|
||||
]
|
||||
|
||||
def get_status(self, o):
|
||||
try:
|
||||
if o.local_track_file is not None:
|
||||
return 'imported'
|
||||
except music_models.TrackFile.DoesNotExist:
|
||||
pass
|
||||
for job in o.import_jobs.all():
|
||||
if job.status == 'pending':
|
||||
return 'import_pending'
|
||||
return 'not_imported'
|
||||
|
||||
|
||||
class FollowSerializer(serializers.Serializer):
|
||||
id = serializers.URLField()
|
||||
object = serializers.URLField()
|
||||
actor = serializers.URLField()
|
||||
id = serializers.URLField(max_length=500)
|
||||
object = serializers.URLField(max_length=500)
|
||||
actor = serializers.URLField(max_length=500)
|
||||
type = serializers.ChoiceField(choices=['Follow'])
|
||||
|
||||
def validate_object(self, v):
|
||||
|
@ -374,8 +390,8 @@ class APIFollowSerializer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class AcceptFollowSerializer(serializers.Serializer):
|
||||
id = serializers.URLField()
|
||||
actor = serializers.URLField()
|
||||
id = serializers.URLField(max_length=500)
|
||||
actor = serializers.URLField(max_length=500)
|
||||
object = FollowSerializer()
|
||||
type = serializers.ChoiceField(choices=['Accept'])
|
||||
|
||||
|
@ -417,8 +433,8 @@ class AcceptFollowSerializer(serializers.Serializer):
|
|||
|
||||
|
||||
class UndoFollowSerializer(serializers.Serializer):
|
||||
id = serializers.URLField()
|
||||
actor = serializers.URLField()
|
||||
id = serializers.URLField(max_length=500)
|
||||
actor = serializers.URLField(max_length=500)
|
||||
object = FollowSerializer()
|
||||
type = serializers.ChoiceField(choices=['Undo'])
|
||||
|
||||
|
@ -459,9 +475,9 @@ class UndoFollowSerializer(serializers.Serializer):
|
|||
|
||||
class ActorWebfingerSerializer(serializers.Serializer):
|
||||
subject = serializers.CharField()
|
||||
aliases = serializers.ListField(child=serializers.URLField())
|
||||
aliases = serializers.ListField(child=serializers.URLField(max_length=500))
|
||||
links = serializers.ListField()
|
||||
actor_url = serializers.URLField(required=False)
|
||||
actor_url = serializers.URLField(max_length=500, required=False)
|
||||
|
||||
def validate(self, validated_data):
|
||||
validated_data['actor_url'] = None
|
||||
|
@ -496,8 +512,8 @@ class ActorWebfingerSerializer(serializers.Serializer):
|
|||
|
||||
|
||||
class ActivitySerializer(serializers.Serializer):
|
||||
actor = serializers.URLField()
|
||||
id = serializers.URLField(required=False)
|
||||
actor = serializers.URLField(max_length=500)
|
||||
id = serializers.URLField(max_length=500, required=False)
|
||||
type = serializers.ChoiceField(
|
||||
choices=[(c, c) for c in activity.ACTIVITY_TYPES])
|
||||
object = serializers.JSONField()
|
||||
|
@ -539,8 +555,8 @@ class ActivitySerializer(serializers.Serializer):
|
|||
|
||||
|
||||
class ObjectSerializer(serializers.Serializer):
|
||||
id = serializers.URLField()
|
||||
url = serializers.URLField(required=False, allow_null=True)
|
||||
id = serializers.URLField(max_length=500)
|
||||
url = serializers.URLField(max_length=500, required=False, allow_null=True)
|
||||
type = serializers.ChoiceField(
|
||||
choices=[(c, c) for c in activity.OBJECT_TYPES])
|
||||
content = serializers.CharField(
|
||||
|
@ -554,16 +570,16 @@ class ObjectSerializer(serializers.Serializer):
|
|||
updated = serializers.DateTimeField(
|
||||
required=False, allow_null=True)
|
||||
to = serializers.ListField(
|
||||
child=serializers.URLField(),
|
||||
child=serializers.URLField(max_length=500),
|
||||
required=False, allow_null=True)
|
||||
cc = serializers.ListField(
|
||||
child=serializers.URLField(),
|
||||
child=serializers.URLField(max_length=500),
|
||||
required=False, allow_null=True)
|
||||
bto = serializers.ListField(
|
||||
child=serializers.URLField(),
|
||||
child=serializers.URLField(max_length=500),
|
||||
required=False, allow_null=True)
|
||||
bcc = serializers.ListField(
|
||||
child=serializers.URLField(),
|
||||
child=serializers.URLField(max_length=500),
|
||||
required=False, allow_null=True)
|
||||
|
||||
OBJECT_SERIALIZERS = {
|
||||
|
@ -575,10 +591,10 @@ OBJECT_SERIALIZERS = {
|
|||
class PaginatedCollectionSerializer(serializers.Serializer):
|
||||
type = serializers.ChoiceField(choices=['Collection'])
|
||||
totalItems = serializers.IntegerField(min_value=0)
|
||||
actor = serializers.URLField()
|
||||
id = serializers.URLField()
|
||||
first = serializers.URLField()
|
||||
last = serializers.URLField()
|
||||
actor = serializers.URLField(max_length=500)
|
||||
id = serializers.URLField(max_length=500)
|
||||
first = serializers.URLField(max_length=500)
|
||||
last = serializers.URLField(max_length=500)
|
||||
|
||||
def to_representation(self, conf):
|
||||
paginator = Paginator(
|
||||
|
@ -607,13 +623,13 @@ class CollectionPageSerializer(serializers.Serializer):
|
|||
type = serializers.ChoiceField(choices=['CollectionPage'])
|
||||
totalItems = serializers.IntegerField(min_value=0)
|
||||
items = serializers.ListField()
|
||||
actor = serializers.URLField()
|
||||
id = serializers.URLField()
|
||||
first = serializers.URLField()
|
||||
last = serializers.URLField()
|
||||
next = serializers.URLField(required=False)
|
||||
prev = serializers.URLField(required=False)
|
||||
partOf = serializers.URLField()
|
||||
actor = serializers.URLField(max_length=500)
|
||||
id = serializers.URLField(max_length=500)
|
||||
first = serializers.URLField(max_length=500)
|
||||
last = serializers.URLField(max_length=500)
|
||||
next = serializers.URLField(max_length=500, required=False)
|
||||
prev = serializers.URLField(max_length=500, required=False)
|
||||
partOf = serializers.URLField(max_length=500)
|
||||
|
||||
def validate_items(self, v):
|
||||
item_serializer = self.context.get('item_serializer')
|
||||
|
@ -698,7 +714,7 @@ class AudioMetadataSerializer(serializers.Serializer):
|
|||
|
||||
class AudioSerializer(serializers.Serializer):
|
||||
type = serializers.CharField()
|
||||
id = serializers.URLField()
|
||||
id = serializers.URLField(max_length=500)
|
||||
url = serializers.JSONField()
|
||||
published = serializers.DateTimeField()
|
||||
updated = serializers.DateTimeField(required=False)
|
||||
|
@ -806,3 +822,29 @@ class CollectionSerializer(serializers.Serializer):
|
|||
if self.context.get('include_ap_context', True):
|
||||
d['@context'] = AP_CONTEXT
|
||||
return d
|
||||
|
||||
|
||||
class LibraryTrackActionSerializer(common_serializers.ActionSerializer):
|
||||
actions = ['import']
|
||||
filterset_class = filters.LibraryTrackFilter
|
||||
|
||||
@transaction.atomic
|
||||
def handle_import(self, objects):
|
||||
batch = music_models.ImportBatch.objects.create(
|
||||
source='federation',
|
||||
submitted_by=self.context['submitted_by']
|
||||
)
|
||||
jobs = []
|
||||
for lt in objects:
|
||||
job = music_models.ImportJob(
|
||||
batch=batch,
|
||||
library_track=lt,
|
||||
mbid=lt.mbid,
|
||||
source=lt.url,
|
||||
)
|
||||
jobs.append(job)
|
||||
|
||||
music_models.ImportJob.objects.bulk_create(jobs)
|
||||
music_tasks.import_batch_run.delay(import_batch_id=batch.pk)
|
||||
|
||||
return {'batch': {'id': batch.pk}}
|
||||
|
|
|
@ -15,7 +15,7 @@ from rest_framework.serializers import ValidationError
|
|||
|
||||
from funkwhale_api.common import preferences
|
||||
from funkwhale_api.common import utils as funkwhale_utils
|
||||
from funkwhale_api.music.models import TrackFile
|
||||
from funkwhale_api.music import models as music_models
|
||||
from funkwhale_api.users.permissions import HasUserPermission
|
||||
|
||||
from . import activity
|
||||
|
@ -148,7 +148,9 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
|
|||
def list(self, request, *args, **kwargs):
|
||||
page = request.GET.get('page')
|
||||
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
qs = TrackFile.objects.order_by('-creation_date').select_related(
|
||||
qs = music_models.TrackFile.objects.order_by(
|
||||
'-creation_date'
|
||||
).select_related(
|
||||
'track__artist',
|
||||
'track__album__artist'
|
||||
).filter(library_track__isnull=True)
|
||||
|
@ -294,7 +296,7 @@ class LibraryTrackViewSet(
|
|||
'library__actor',
|
||||
'library__follow',
|
||||
'local_track_file',
|
||||
)
|
||||
).prefetch_related('import_jobs')
|
||||
filter_class = filters.LibraryTrackFilter
|
||||
serializer_class = serializers.APILibraryTrackSerializer
|
||||
ordering_fields = (
|
||||
|
@ -307,3 +309,16 @@ class LibraryTrackViewSet(
|
|||
'fetched_date',
|
||||
'published_date',
|
||||
)
|
||||
|
||||
@list_route(methods=['post'])
|
||||
def action(self, request, *args, **kwargs):
|
||||
queryset = models.LibraryTrack.objects.filter(
|
||||
local_track_file__isnull=True)
|
||||
serializer = serializers.LibraryTrackActionSerializer(
|
||||
request.data,
|
||||
queryset=queryset,
|
||||
context={'submitted_by': request.user}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
result = serializer.save()
|
||||
return response.Response(result, status=200)
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.activity import serializers as activity_serializers
|
||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||
from funkwhale_api.music.serializers import TrackActivitySerializer
|
||||
from funkwhale_api.users.serializers import UserActivitySerializer
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ from rest_framework.decorators import detail_route
|
|||
|
||||
from funkwhale_api.activity import record
|
||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||
|
||||
from . import models
|
||||
from . import serializers
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
App that includes all views/serializers and stuff for management API
|
||||
"""
|
|
@ -0,0 +1,25 @@
|
|||
from django.db.models import Count
|
||||
|
||||
from django_filters import rest_framework as filters
|
||||
|
||||
from funkwhale_api.common import fields
|
||||
from funkwhale_api.music import models as music_models
|
||||
|
||||
|
||||
class ManageTrackFileFilterSet(filters.FilterSet):
|
||||
q = fields.SearchFilter(search_fields=[
|
||||
'track__title',
|
||||
'track__album__title',
|
||||
'track__artist__name',
|
||||
'source',
|
||||
])
|
||||
|
||||
class Meta:
|
||||
model = music_models.TrackFile
|
||||
fields = [
|
||||
'q',
|
||||
'track__album',
|
||||
'track__artist',
|
||||
'track',
|
||||
'library_track'
|
||||
]
|
|
@ -0,0 +1,82 @@
|
|||
from django.db import transaction
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.common import serializers as common_serializers
|
||||
from funkwhale_api.music import models as music_models
|
||||
|
||||
from . import filters
|
||||
|
||||
|
||||
class ManageTrackFileArtistSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = music_models.Artist
|
||||
fields = [
|
||||
'id',
|
||||
'mbid',
|
||||
'creation_date',
|
||||
'name',
|
||||
]
|
||||
|
||||
|
||||
class ManageTrackFileAlbumSerializer(serializers.ModelSerializer):
|
||||
artist = ManageTrackFileArtistSerializer()
|
||||
|
||||
class Meta:
|
||||
model = music_models.Album
|
||||
fields = (
|
||||
'id',
|
||||
'mbid',
|
||||
'title',
|
||||
'artist',
|
||||
'release_date',
|
||||
'cover',
|
||||
'creation_date',
|
||||
)
|
||||
|
||||
|
||||
class ManageTrackFileTrackSerializer(serializers.ModelSerializer):
|
||||
artist = ManageTrackFileArtistSerializer()
|
||||
album = ManageTrackFileAlbumSerializer()
|
||||
|
||||
class Meta:
|
||||
model = music_models.Track
|
||||
fields = (
|
||||
'id',
|
||||
'mbid',
|
||||
'title',
|
||||
'album',
|
||||
'artist',
|
||||
'creation_date',
|
||||
'position',
|
||||
)
|
||||
|
||||
|
||||
class ManageTrackFileSerializer(serializers.ModelSerializer):
|
||||
track = ManageTrackFileTrackSerializer()
|
||||
|
||||
class Meta:
|
||||
model = music_models.TrackFile
|
||||
fields = (
|
||||
'id',
|
||||
'path',
|
||||
'source',
|
||||
'filename',
|
||||
'mimetype',
|
||||
'track',
|
||||
'duration',
|
||||
'mimetype',
|
||||
'bitrate',
|
||||
'size',
|
||||
'path',
|
||||
'library_track',
|
||||
)
|
||||
|
||||
|
||||
class ManageTrackFileActionSerializer(common_serializers.ActionSerializer):
|
||||
actions = ['delete']
|
||||
dangerous_actions = ['delete']
|
||||
filterset_class = filters.ManageTrackFileFilterSet
|
||||
|
||||
@transaction.atomic
|
||||
def handle_delete(self, objects):
|
||||
return objects.delete()
|
|
@ -0,0 +1,11 @@
|
|||
from django.conf.urls import include, url
|
||||
from . import views
|
||||
|
||||
from rest_framework import routers
|
||||
library_router = routers.SimpleRouter()
|
||||
library_router.register(r'track-files', views.ManageTrackFileViewSet, 'track-files')
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^library/',
|
||||
include((library_router.urls, 'instance'), namespace='library')),
|
||||
]
|
|
@ -0,0 +1,49 @@
|
|||
from rest_framework import mixins
|
||||
from rest_framework import response
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import list_route
|
||||
|
||||
from funkwhale_api.music import models as music_models
|
||||
from funkwhale_api.users.permissions import HasUserPermission
|
||||
|
||||
from . import filters
|
||||
from . import serializers
|
||||
|
||||
|
||||
class ManageTrackFileViewSet(
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
queryset = (
|
||||
music_models.TrackFile.objects.all()
|
||||
.select_related(
|
||||
'track__artist',
|
||||
'track__album__artist',
|
||||
'library_track')
|
||||
.order_by('-id')
|
||||
)
|
||||
serializer_class = serializers.ManageTrackFileSerializer
|
||||
filter_class = filters.ManageTrackFileFilterSet
|
||||
permission_classes = (HasUserPermission,)
|
||||
required_permissions = ['library']
|
||||
ordering_fields = [
|
||||
'accessed_date',
|
||||
'modification_date',
|
||||
'creation_date',
|
||||
'track__artist__name',
|
||||
'bitrate',
|
||||
'size',
|
||||
'duration',
|
||||
]
|
||||
|
||||
@list_route(methods=['post'])
|
||||
def action(self, request, *args, **kwargs):
|
||||
queryset = self.get_queryset()
|
||||
serializer = serializers.ManageTrackFileActionSerializer(
|
||||
request.data,
|
||||
queryset=queryset,
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
result = serializer.save()
|
||||
return response.Response(result, status=200)
|
|
@ -117,6 +117,11 @@ class ImportJobFactory(factory.django.DjangoModelFactory):
|
|||
status='finished',
|
||||
audio_file=None,
|
||||
)
|
||||
with_audio_file = factory.Trait(
|
||||
status='finished',
|
||||
audio_file=factory.django.FileField(
|
||||
from_path=os.path.join(SAMPLES_PATH, 'test.ogg')),
|
||||
)
|
||||
|
||||
|
||||
@registry.register(name='music.FileImportJob')
|
||||
|
|
|
@ -32,6 +32,33 @@ class ArtistFilter(ListenableMixin):
|
|||
}
|
||||
|
||||
|
||||
class TrackFilter(filters.FilterSet):
|
||||
q = fields.SearchFilter(search_fields=[
|
||||
'title',
|
||||
'album__title',
|
||||
'artist__name',
|
||||
])
|
||||
listenable = filters.BooleanFilter(name='_', method='filter_listenable')
|
||||
|
||||
class Meta:
|
||||
model = models.Track
|
||||
fields = {
|
||||
'title': ['exact', 'iexact', 'startswith', 'icontains'],
|
||||
'listenable': ['exact'],
|
||||
'artist': ['exact'],
|
||||
'album': ['exact'],
|
||||
}
|
||||
|
||||
def filter_listenable(self, queryset, name, value):
|
||||
queryset = queryset.annotate(
|
||||
files_count=Count('files')
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(files_count__gt=0)
|
||||
else:
|
||||
return queryset.filter(files_count=0)
|
||||
|
||||
|
||||
class ImportBatchFilter(filters.FilterSet):
|
||||
q = fields.SearchFilter(search_fields=[
|
||||
'submitted_by__username',
|
||||
|
@ -67,7 +94,12 @@ class ImportJobFilter(filters.FilterSet):
|
|||
|
||||
class AlbumFilter(ListenableMixin):
|
||||
listenable = filters.BooleanFilter(name='_', method='filter_listenable')
|
||||
q = fields.SearchFilter(search_fields=[
|
||||
'title',
|
||||
'artist__name'
|
||||
'source',
|
||||
])
|
||||
|
||||
class Meta:
|
||||
model = models.Album
|
||||
fields = ['listenable']
|
||||
fields = ['listenable', 'q', 'artist']
|
||||
|
|
|
@ -9,7 +9,13 @@ class TagNotFound(KeyError):
|
|||
pass
|
||||
|
||||
|
||||
class UnsupportedTag(KeyError):
|
||||
pass
|
||||
|
||||
|
||||
def get_id3_tag(f, k):
|
||||
if k == 'pictures':
|
||||
return f.tags.getall('APIC')
|
||||
# First we try to grab the standard key
|
||||
try:
|
||||
return f.tags[k].text[0]
|
||||
|
@ -28,13 +34,39 @@ def get_id3_tag(f, k):
|
|||
raise TagNotFound(k)
|
||||
|
||||
|
||||
def clean_id3_pictures(apic):
|
||||
pictures = []
|
||||
for p in list(apic):
|
||||
pictures.append({
|
||||
'mimetype': p.mime,
|
||||
'content': p.data,
|
||||
'description': p.desc,
|
||||
'type': p.type.real,
|
||||
})
|
||||
return pictures
|
||||
|
||||
|
||||
def get_flac_tag(f, k):
|
||||
if k == 'pictures':
|
||||
return f.pictures
|
||||
try:
|
||||
return f.get(k)[0]
|
||||
return f.get(k, [])[0]
|
||||
except (KeyError, IndexError):
|
||||
raise TagNotFound(k)
|
||||
|
||||
|
||||
def clean_flac_pictures(apic):
|
||||
pictures = []
|
||||
for p in list(apic):
|
||||
pictures.append({
|
||||
'mimetype': p.mime,
|
||||
'content': p.data,
|
||||
'description': p.desc,
|
||||
'type': p.type.real,
|
||||
})
|
||||
return pictures
|
||||
|
||||
|
||||
def get_mp3_recording_id(f, k):
|
||||
try:
|
||||
return [
|
||||
|
@ -73,35 +105,51 @@ CONF = {
|
|||
'field': 'TRACKNUMBER',
|
||||
'to_application': convert_track_number
|
||||
},
|
||||
'title': {
|
||||
'field': 'title'
|
||||
},
|
||||
'artist': {
|
||||
'field': 'artist'
|
||||
},
|
||||
'album': {
|
||||
'field': 'album'
|
||||
},
|
||||
'title': {},
|
||||
'artist': {},
|
||||
'album': {},
|
||||
'date': {
|
||||
'field': 'date',
|
||||
'to_application': lambda v: arrow.get(v).date()
|
||||
},
|
||||
'musicbrainz_albumid': {
|
||||
'field': 'musicbrainz_albumid'
|
||||
},
|
||||
'musicbrainz_artistid': {
|
||||
'field': 'musicbrainz_artistid'
|
||||
},
|
||||
'musicbrainz_albumid': {},
|
||||
'musicbrainz_artistid': {},
|
||||
'musicbrainz_recordingid': {
|
||||
'field': 'musicbrainz_trackid'
|
||||
},
|
||||
}
|
||||
},
|
||||
'MP3': {
|
||||
'getter': get_id3_tag,
|
||||
'OggTheora': {
|
||||
'getter': lambda f, k: f[k][0],
|
||||
'fields': {
|
||||
'track_number': {
|
||||
'field': 'TPOS',
|
||||
'field': 'TRACKNUMBER',
|
||||
'to_application': convert_track_number
|
||||
},
|
||||
'title': {},
|
||||
'artist': {},
|
||||
'album': {},
|
||||
'date': {
|
||||
'field': 'date',
|
||||
'to_application': lambda v: arrow.get(v).date()
|
||||
},
|
||||
'musicbrainz_albumid': {
|
||||
'field': 'MusicBrainz Album Id'
|
||||
},
|
||||
'musicbrainz_artistid': {
|
||||
'field': 'MusicBrainz Artist Id'
|
||||
},
|
||||
'musicbrainz_recordingid': {
|
||||
'field': 'MusicBrainz Track Id'
|
||||
},
|
||||
}
|
||||
},
|
||||
'MP3': {
|
||||
'getter': get_id3_tag,
|
||||
'clean_pictures': clean_id3_pictures,
|
||||
'fields': {
|
||||
'track_number': {
|
||||
'field': 'TRCK',
|
||||
'to_application': convert_track_number
|
||||
},
|
||||
'title': {
|
||||
|
@ -127,37 +175,31 @@ CONF = {
|
|||
'field': 'UFID',
|
||||
'getter': get_mp3_recording_id,
|
||||
},
|
||||
'pictures': {},
|
||||
}
|
||||
},
|
||||
'FLAC': {
|
||||
'getter': get_flac_tag,
|
||||
'clean_pictures': clean_flac_pictures,
|
||||
'fields': {
|
||||
'track_number': {
|
||||
'field': 'tracknumber',
|
||||
'to_application': convert_track_number
|
||||
},
|
||||
'title': {
|
||||
'field': 'title'
|
||||
},
|
||||
'artist': {
|
||||
'field': 'artist'
|
||||
},
|
||||
'album': {
|
||||
'field': 'album'
|
||||
},
|
||||
'title': {},
|
||||
'artist': {},
|
||||
'album': {},
|
||||
'date': {
|
||||
'field': 'date',
|
||||
'to_application': lambda v: arrow.get(str(v)).date()
|
||||
},
|
||||
'musicbrainz_albumid': {
|
||||
'field': 'musicbrainz_albumid'
|
||||
},
|
||||
'musicbrainz_artistid': {
|
||||
'field': 'musicbrainz_artistid'
|
||||
},
|
||||
'musicbrainz_albumid': {},
|
||||
'musicbrainz_artistid': {},
|
||||
'musicbrainz_recordingid': {
|
||||
'field': 'musicbrainz_trackid'
|
||||
},
|
||||
'test': {},
|
||||
'pictures': {},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
@ -179,8 +221,12 @@ class Metadata(object):
|
|||
return f.__class__.__name__
|
||||
|
||||
def get(self, key, default=NODEFAULT):
|
||||
field_conf = self._conf['fields'][key]
|
||||
real_key = field_conf['field']
|
||||
try:
|
||||
field_conf = self._conf['fields'][key]
|
||||
except KeyError:
|
||||
raise UnsupportedTag(
|
||||
'{} is not supported for this file format'.format(key))
|
||||
real_key = field_conf.get('field', key)
|
||||
try:
|
||||
getter = field_conf.get('getter', self._conf['getter'])
|
||||
v = getter(self._file, real_key)
|
||||
|
@ -196,3 +242,16 @@ class Metadata(object):
|
|||
if field:
|
||||
v = field.to_python(v)
|
||||
return v
|
||||
|
||||
def get_picture(self, picture_type='cover_front'):
|
||||
ptype = getattr(mutagen.id3.PictureType, picture_type.upper())
|
||||
try:
|
||||
pictures = self.get('pictures')
|
||||
except (UnsupportedTag, TagNotFound):
|
||||
return
|
||||
|
||||
cleaner = self._conf.get('clean_pictures', lambda v: v)
|
||||
pictures = cleaner(pictures)
|
||||
for p in pictures:
|
||||
if p['type'] == ptype:
|
||||
return p
|
||||
|
|
|
@ -23,6 +23,7 @@ from funkwhale_api import downloader
|
|||
from funkwhale_api import musicbrainz
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
from . import importers
|
||||
from . import metadata
|
||||
from . import utils
|
||||
|
||||
|
||||
|
@ -80,6 +81,12 @@ class ArtistQuerySet(models.QuerySet):
|
|||
def with_albums_count(self):
|
||||
return self.annotate(_albums_count=models.Count('albums'))
|
||||
|
||||
def with_albums(self):
|
||||
return self.prefetch_related(
|
||||
models.Prefetch(
|
||||
'albums', queryset=Album.objects.with_tracks_count())
|
||||
)
|
||||
|
||||
|
||||
class Artist(APIModelMixin):
|
||||
name = models.CharField(max_length=255)
|
||||
|
@ -186,10 +193,20 @@ class Album(APIModelMixin):
|
|||
}
|
||||
objects = AlbumQuerySet.as_manager()
|
||||
|
||||
def get_image(self):
|
||||
image_data = musicbrainz.api.images.get_front(str(self.mbid))
|
||||
f = ContentFile(image_data)
|
||||
self.cover.save('{0}.jpg'.format(self.mbid), f)
|
||||
def get_image(self, data=None):
|
||||
if data:
|
||||
f = ContentFile(data['content'])
|
||||
extensions = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
}
|
||||
extension = extensions.get(data['mimetype'], 'jpg')
|
||||
self.cover.save('{}.{}'.format(self.uuid, extension), f)
|
||||
else:
|
||||
image_data = musicbrainz.api.images.get_front(str(self.mbid))
|
||||
f = ContentFile(image_data)
|
||||
self.cover.save('{0}.jpg'.format(self.mbid), f)
|
||||
return self.cover.file
|
||||
|
||||
def __str__(self):
|
||||
|
@ -313,11 +330,8 @@ class Lyrics(models.Model):
|
|||
class TrackQuerySet(models.QuerySet):
|
||||
def for_nested_serialization(self):
|
||||
return (self.select_related()
|
||||
.select_related('album__artist')
|
||||
.prefetch_related(
|
||||
'tags',
|
||||
'files',
|
||||
'artist__albums__tracks__tags'))
|
||||
.select_related('album__artist', 'artist')
|
||||
.prefetch_related('files'))
|
||||
|
||||
|
||||
class Track(APIModelMixin):
|
||||
|
@ -519,6 +533,12 @@ class TrackFile(models.Model):
|
|||
self.mimetype = utils.guess_mimetype(self.audio_file)
|
||||
return super().save(**kwargs)
|
||||
|
||||
def get_metadata(self):
|
||||
audio_file = self.get_audio_file()
|
||||
if not audio_file:
|
||||
return
|
||||
return metadata.Metadata(audio_file)
|
||||
|
||||
|
||||
IMPORT_STATUS_CHOICES = (
|
||||
('pending', 'Pending'),
|
||||
|
|
|
@ -10,9 +10,6 @@ from funkwhale_api.federation import models
|
|||
class Listen(BasePermission):
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not settings.PROTECT_AUDIO_FILES:
|
||||
return True
|
||||
|
||||
if not preferences.get('common__api_authentication_required'):
|
||||
return True
|
||||
|
||||
|
|
|
@ -13,24 +13,38 @@ from . import models
|
|||
from . import tasks
|
||||
|
||||
|
||||
class TagSerializer(serializers.ModelSerializer):
|
||||
class ArtistAlbumSerializer(serializers.ModelSerializer):
|
||||
tracks_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ('id', 'name', 'slug')
|
||||
model = models.Album
|
||||
fields = (
|
||||
'id',
|
||||
'mbid',
|
||||
'title',
|
||||
'artist',
|
||||
'release_date',
|
||||
'cover',
|
||||
'creation_date',
|
||||
'tracks_count',
|
||||
)
|
||||
|
||||
def get_tracks_count(self, o):
|
||||
return o._tracks_count
|
||||
|
||||
|
||||
class SimpleArtistSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Artist
|
||||
fields = ('id', 'mbid', 'name', 'creation_date')
|
||||
|
||||
|
||||
class ArtistSerializer(serializers.ModelSerializer):
|
||||
tags = TagSerializer(many=True, read_only=True)
|
||||
class ArtistWithAlbumsSerializer(serializers.ModelSerializer):
|
||||
albums = ArtistAlbumSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Artist
|
||||
fields = ('id', 'mbid', 'name', 'tags', 'creation_date')
|
||||
fields = (
|
||||
'id',
|
||||
'mbid',
|
||||
'name',
|
||||
'creation_date',
|
||||
'albums',
|
||||
)
|
||||
|
||||
|
||||
class TrackFileSerializer(serializers.ModelSerializer):
|
||||
|
@ -62,71 +76,110 @@ class TrackFileSerializer(serializers.ModelSerializer):
|
|||
return url
|
||||
|
||||
|
||||
class SimpleAlbumSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Album
|
||||
fields = ('id', 'mbid', 'title', 'release_date', 'cover')
|
||||
|
||||
|
||||
class AlbumSerializer(serializers.ModelSerializer):
|
||||
tags = TagSerializer(many=True, read_only=True)
|
||||
class Meta:
|
||||
model = models.Album
|
||||
fields = ('id', 'mbid', 'title', 'cover', 'release_date', 'tags')
|
||||
|
||||
|
||||
class LyricsMixin(serializers.ModelSerializer):
|
||||
lyrics = serializers.SerializerMethodField()
|
||||
|
||||
def get_lyrics(self, obj):
|
||||
return obj.get_lyrics_url()
|
||||
|
||||
|
||||
class TrackSerializer(LyricsMixin):
|
||||
class AlbumTrackSerializer(serializers.ModelSerializer):
|
||||
files = TrackFileSerializer(many=True, read_only=True)
|
||||
tags = TagSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Track
|
||||
fields = (
|
||||
'id',
|
||||
'mbid',
|
||||
'title',
|
||||
'album',
|
||||
'artist',
|
||||
'creation_date',
|
||||
'files',
|
||||
'tags',
|
||||
'position',
|
||||
'lyrics')
|
||||
)
|
||||
|
||||
|
||||
class TrackSerializerNested(LyricsMixin):
|
||||
artist = ArtistSerializer()
|
||||
files = TrackFileSerializer(many=True, read_only=True)
|
||||
album = SimpleAlbumSerializer(read_only=True)
|
||||
tags = TagSerializer(many=True, read_only=True)
|
||||
|
||||
class ArtistSimpleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Track
|
||||
fields = ('id', 'mbid', 'title', 'artist', 'files', 'album', 'tags', 'lyrics')
|
||||
model = models.Artist
|
||||
fields = (
|
||||
'id',
|
||||
'mbid',
|
||||
'name',
|
||||
'creation_date',
|
||||
)
|
||||
|
||||
|
||||
class AlbumSerializerNested(serializers.ModelSerializer):
|
||||
tracks = TrackSerializer(many=True, read_only=True)
|
||||
artist = SimpleArtistSerializer()
|
||||
tags = TagSerializer(many=True, read_only=True)
|
||||
class AlbumSerializer(serializers.ModelSerializer):
|
||||
tracks = serializers.SerializerMethodField()
|
||||
artist = ArtistSimpleSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Album
|
||||
fields = ('id', 'mbid', 'title', 'cover', 'artist', 'release_date', 'tracks', 'tags')
|
||||
fields = (
|
||||
'id',
|
||||
'mbid',
|
||||
'title',
|
||||
'artist',
|
||||
'tracks',
|
||||
'release_date',
|
||||
'cover',
|
||||
'creation_date',
|
||||
)
|
||||
|
||||
def get_tracks(self, o):
|
||||
ordered_tracks = sorted(
|
||||
o.tracks.all(),
|
||||
key=lambda v: (v.position, v.title) if v.position else (99999, v.title)
|
||||
)
|
||||
return AlbumTrackSerializer(ordered_tracks, many=True).data
|
||||
|
||||
|
||||
class ArtistSerializerNested(serializers.ModelSerializer):
|
||||
albums = AlbumSerializerNested(many=True, read_only=True)
|
||||
tags = TagSerializer(many=True, read_only=True)
|
||||
class TrackAlbumSerializer(serializers.ModelSerializer):
|
||||
artist = ArtistSimpleSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Artist
|
||||
fields = ('id', 'mbid', 'name', 'albums', 'tags')
|
||||
model = models.Album
|
||||
fields = (
|
||||
'id',
|
||||
'mbid',
|
||||
'title',
|
||||
'artist',
|
||||
'release_date',
|
||||
'cover',
|
||||
'creation_date',
|
||||
)
|
||||
|
||||
|
||||
class TrackSerializer(serializers.ModelSerializer):
|
||||
files = TrackFileSerializer(many=True, read_only=True)
|
||||
artist = ArtistSimpleSerializer(read_only=True)
|
||||
album = TrackAlbumSerializer(read_only=True)
|
||||
lyrics = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Track
|
||||
fields = (
|
||||
'id',
|
||||
'mbid',
|
||||
'title',
|
||||
'album',
|
||||
'artist',
|
||||
'creation_date',
|
||||
'files',
|
||||
'position',
|
||||
'lyrics',
|
||||
)
|
||||
|
||||
def get_lyrics(self, obj):
|
||||
return obj.get_lyrics_url()
|
||||
|
||||
|
||||
class TagSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ('id', 'name', 'slug')
|
||||
|
||||
|
||||
class SimpleAlbumSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Album
|
||||
fields = ('id', 'mbid', 'title', 'release_date', 'cover')
|
||||
|
||||
|
||||
class LyricsSerializer(serializers.ModelSerializer):
|
||||
|
@ -197,28 +250,6 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
|
|||
return 'Audio'
|
||||
|
||||
|
||||
class SubmitFederationTracksSerializer(serializers.Serializer):
|
||||
library_tracks = serializers.PrimaryKeyRelatedField(
|
||||
many=True,
|
||||
queryset=LibraryTrack.objects.filter(local_track_file__isnull=True),
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, **kwargs):
|
||||
batch = models.ImportBatch.objects.create(
|
||||
source='federation',
|
||||
**kwargs
|
||||
)
|
||||
for lt in self.validated_data['library_tracks']:
|
||||
models.ImportJob.objects.create(
|
||||
batch=batch,
|
||||
library_track=lt,
|
||||
mbid=lt.mbid,
|
||||
source=lt.url,
|
||||
)
|
||||
return batch
|
||||
|
||||
|
||||
class ImportJobRunSerializer(serializers.Serializer):
|
||||
jobs = serializers.PrimaryKeyRelatedField(
|
||||
many=True,
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import logging
|
||||
import os
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from musicbrainzngs import ResponseError
|
||||
|
||||
from funkwhale_api.common import preferences
|
||||
from funkwhale_api.federation import activity
|
||||
from funkwhale_api.federation import actors
|
||||
|
@ -9,13 +12,15 @@ from funkwhale_api.federation import models as federation_models
|
|||
from funkwhale_api.federation import serializers as federation_serializers
|
||||
from funkwhale_api.taskapp import celery
|
||||
from funkwhale_api.providers.acoustid import get_acoustid_client
|
||||
from funkwhale_api.providers.audiofile.tasks import import_track_data_from_path
|
||||
from funkwhale_api.providers.audiofile import tasks as audiofile_tasks
|
||||
|
||||
from django.conf import settings
|
||||
from . import models
|
||||
from . import lyrics as lyrics_utils
|
||||
from . import utils as music_utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery.app.task(name='acoustid.set_on_track_file')
|
||||
@celery.require_instance(models.TrackFile, 'track_file')
|
||||
|
@ -73,13 +78,16 @@ def import_track_from_remote(library_track):
|
|||
library_track.title, artist=artist, album=album)[0]
|
||||
|
||||
|
||||
def _do_import(import_job, replace=False, use_acoustid=True):
|
||||
def _do_import(import_job, replace=False, use_acoustid=False):
|
||||
logger.info('[Import Job %s] starting job', import_job.pk)
|
||||
from_file = bool(import_job.audio_file)
|
||||
mbid = import_job.mbid
|
||||
acoustid_track_id = None
|
||||
duration = None
|
||||
track = None
|
||||
use_acoustid = use_acoustid and preferences.get('providers_acoustid__api_key')
|
||||
# use_acoustid = use_acoustid and preferences.get('providers_acoustid__api_key')
|
||||
# Acoustid is not reliable, we disable it for now.
|
||||
use_acoustid = False
|
||||
if not mbid and use_acoustid and from_file:
|
||||
# we try to deduce mbid from acoustid
|
||||
client = get_acoustid_client()
|
||||
|
@ -89,14 +97,32 @@ def _do_import(import_job, replace=False, use_acoustid=True):
|
|||
mbid = match['recordings'][0]['id']
|
||||
acoustid_track_id = match['id']
|
||||
if mbid:
|
||||
logger.info(
|
||||
'[Import Job %s] importing track from musicbrainz recording %s',
|
||||
import_job.pk,
|
||||
str(mbid))
|
||||
track, _ = models.Track.get_or_create_from_api(mbid=mbid)
|
||||
elif import_job.audio_file:
|
||||
track = import_track_data_from_path(import_job.audio_file.path)
|
||||
logger.info(
|
||||
'[Import Job %s] importing track from uploaded track data at %s',
|
||||
import_job.pk,
|
||||
import_job.audio_file.path)
|
||||
track = audiofile_tasks.import_track_data_from_path(
|
||||
import_job.audio_file.path)
|
||||
elif import_job.library_track:
|
||||
logger.info(
|
||||
'[Import Job %s] importing track from federated library track %s',
|
||||
import_job.pk,
|
||||
import_job.library_track.pk)
|
||||
track = import_track_from_remote(import_job.library_track)
|
||||
elif import_job.source.startswith('file://'):
|
||||
track = import_track_data_from_path(
|
||||
import_job.source.replace('file://', '', 1))
|
||||
tf_path = import_job.source.replace('file://', '', 1)
|
||||
logger.info(
|
||||
'[Import Job %s] importing track from local track data at %s',
|
||||
import_job.pk,
|
||||
tf_path)
|
||||
track = audiofile_tasks.import_track_data_from_path(
|
||||
tf_path)
|
||||
else:
|
||||
raise ValueError(
|
||||
'Not enough data to process import, '
|
||||
|
@ -104,8 +130,13 @@ def _do_import(import_job, replace=False, use_acoustid=True):
|
|||
|
||||
track_file = None
|
||||
if replace:
|
||||
logger.info(
|
||||
'[Import Job %s] replacing existing audio file', import_job.pk)
|
||||
track_file = track.files.first()
|
||||
elif track.files.count() > 0:
|
||||
logger.info(
|
||||
'[Import Job %s] skipping, we already have a file for this track',
|
||||
import_job.pk)
|
||||
if import_job.audio_file:
|
||||
import_job.audio_file.delete()
|
||||
import_job.status = 'skipped'
|
||||
|
@ -129,6 +160,9 @@ def _do_import(import_job, replace=False, use_acoustid=True):
|
|||
pass
|
||||
elif not import_job.audio_file and not import_job.source.startswith('file://'):
|
||||
# not an implace import, and we have a source, so let's download it
|
||||
logger.info(
|
||||
'[Import Job %s] downloading audio file from remote',
|
||||
import_job.pk)
|
||||
track_file.download_file()
|
||||
elif not import_job.audio_file and import_job.source.startswith('file://'):
|
||||
# in place import, we set mimetype from extension
|
||||
|
@ -136,23 +170,96 @@ def _do_import(import_job, replace=False, use_acoustid=True):
|
|||
track_file.mimetype = music_utils.get_type_from_ext(ext)
|
||||
track_file.set_audio_data()
|
||||
track_file.save()
|
||||
# if no cover is set on track album, we try to update it as well:
|
||||
if not track.album.cover:
|
||||
logger.info(
|
||||
'[Import Job %s] retrieving album cover',
|
||||
import_job.pk)
|
||||
update_album_cover(track.album, track_file)
|
||||
import_job.status = 'finished'
|
||||
import_job.track_file = track_file
|
||||
if import_job.audio_file:
|
||||
# it's imported on the track, we don't need it anymore
|
||||
import_job.audio_file.delete()
|
||||
import_job.save()
|
||||
|
||||
logger.info(
|
||||
'[Import Job %s] job finished',
|
||||
import_job.pk)
|
||||
return track_file
|
||||
|
||||
|
||||
def update_album_cover(album, track_file, replace=False):
|
||||
if album.cover and not replace:
|
||||
return
|
||||
|
||||
if track_file:
|
||||
# maybe the file has a cover embedded?
|
||||
try:
|
||||
metadata = track_file.get_metadata()
|
||||
except FileNotFoundError:
|
||||
metadata = None
|
||||
if metadata:
|
||||
cover = metadata.get_picture('cover_front')
|
||||
if cover:
|
||||
# best case scenario, cover is embedded in the track
|
||||
logger.info(
|
||||
'[Album %s] Using cover embedded in file',
|
||||
album.pk)
|
||||
return album.get_image(data=cover)
|
||||
if track_file.source and track_file.source.startswith('file://'):
|
||||
# let's look for a cover in the same directory
|
||||
path = os.path.dirname(track_file.source.replace('file://', '', 1))
|
||||
logger.info(
|
||||
'[Album %s] scanning covers from %s',
|
||||
album.pk,
|
||||
path)
|
||||
cover = get_cover_from_fs(path)
|
||||
if cover:
|
||||
return album.get_image(data=cover)
|
||||
if not album.mbid:
|
||||
return
|
||||
try:
|
||||
logger.info(
|
||||
'[Album %s] Fetching cover from musicbrainz release %s',
|
||||
album.pk,
|
||||
str(album.mbid))
|
||||
return album.get_image()
|
||||
except ResponseError as exc:
|
||||
logger.warning(
|
||||
'[Album %s] cannot fetch cover from musicbrainz: %s',
|
||||
album.pk,
|
||||
str(exc))
|
||||
|
||||
|
||||
IMAGE_TYPES = [
|
||||
('jpg', 'image/jpeg'),
|
||||
('png', 'image/png'),
|
||||
]
|
||||
|
||||
def get_cover_from_fs(dir_path):
|
||||
if os.path.exists(dir_path):
|
||||
for e, m in IMAGE_TYPES:
|
||||
cover_path = os.path.join(dir_path, 'cover.{}'.format(e))
|
||||
if not os.path.exists(cover_path):
|
||||
logger.debug('Cover %s does not exists', cover_path)
|
||||
continue
|
||||
with open(cover_path, 'rb') as c:
|
||||
logger.info('Found cover at %s', cover_path)
|
||||
return {
|
||||
'mimetype': m,
|
||||
'content': c.read(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@celery.app.task(name='ImportJob.run', bind=True)
|
||||
@celery.require_instance(
|
||||
models.ImportJob.objects.filter(
|
||||
status__in=['pending', 'errored']),
|
||||
'import_job')
|
||||
def import_job_run(self, import_job, replace=False, use_acoustid=True):
|
||||
def mark_errored():
|
||||
def import_job_run(self, import_job, replace=False, use_acoustid=False):
|
||||
def mark_errored(exc):
|
||||
logger.error('[Import Job %s] Error during import: %s', str(exc))
|
||||
import_job.status = 'errored'
|
||||
import_job.save(update_fields=['status'])
|
||||
|
||||
|
@ -164,12 +271,19 @@ def import_job_run(self, import_job, replace=False, use_acoustid=True):
|
|||
try:
|
||||
self.retry(exc=exc, countdown=30, max_retries=3)
|
||||
except:
|
||||
mark_errored()
|
||||
mark_errored(exc)
|
||||
raise
|
||||
mark_errored()
|
||||
mark_errored(exc)
|
||||
raise
|
||||
|
||||
|
||||
@celery.app.task(name='ImportBatch.run')
|
||||
@celery.require_instance(models.ImportBatch, 'import_batch')
|
||||
def import_batch_run(import_batch):
|
||||
for job_id in import_batch.jobs.order_by('id').values_list('id', flat=True):
|
||||
import_job_run.delay(import_job_id=job_id)
|
||||
|
||||
|
||||
@celery.app.task(name='Lyrics.fetch_content')
|
||||
@celery.require_instance(models.Lyrics, 'lyrics')
|
||||
def fetch_content(lyrics):
|
||||
|
|
|
@ -46,17 +46,6 @@ from . import utils
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SearchMixin(object):
|
||||
search_fields = []
|
||||
|
||||
@list_route(methods=['get'])
|
||||
def search(self, request, *args, **kwargs):
|
||||
query = utils.get_query(request.GET['query'], self.search_fields)
|
||||
queryset = self.get_queryset().filter(query)
|
||||
serializer = self.serializer_class(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class TagViewSetMixin(object):
|
||||
|
||||
def get_queryset(self):
|
||||
|
@ -67,31 +56,25 @@ class TagViewSetMixin(object):
|
|||
return queryset
|
||||
|
||||
|
||||
class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
|
||||
queryset = (
|
||||
models.Artist.objects.all()
|
||||
.prefetch_related(
|
||||
'albums__tracks__files',
|
||||
'albums__tracks__artist',
|
||||
'albums__tracks__tags'))
|
||||
serializer_class = serializers.ArtistSerializerNested
|
||||
class ArtistViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = models.Artist.objects.with_albums()
|
||||
serializer_class = serializers.ArtistWithAlbumsSerializer
|
||||
permission_classes = [ConditionalAuthentication]
|
||||
search_fields = ['name__unaccent']
|
||||
filter_class = filters.ArtistFilter
|
||||
ordering_fields = ('id', 'name', 'creation_date')
|
||||
|
||||
|
||||
class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
|
||||
class AlbumViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = (
|
||||
models.Album.objects.all()
|
||||
.order_by('-creation_date')
|
||||
.order_by('artist', 'release_date')
|
||||
.select_related()
|
||||
.prefetch_related('tracks__tags',
|
||||
'tracks__files'))
|
||||
serializer_class = serializers.AlbumSerializerNested
|
||||
.prefetch_related(
|
||||
'tracks__artist',
|
||||
'tracks__files'))
|
||||
serializer_class = serializers.AlbumSerializer
|
||||
permission_classes = [ConditionalAuthentication]
|
||||
search_fields = ['title__unaccent']
|
||||
ordering_fields = ('creation_date',)
|
||||
ordering_fields = ('creation_date', 'release_date', 'title')
|
||||
filter_class = filters.AlbumFilter
|
||||
|
||||
|
||||
|
@ -108,12 +91,21 @@ class ImportBatchViewSet(
|
|||
)
|
||||
serializer_class = serializers.ImportBatchSerializer
|
||||
permission_classes = (HasUserPermission,)
|
||||
required_permissions = ['library']
|
||||
required_permissions = ['library', 'upload']
|
||||
permission_operator = 'or'
|
||||
filter_class = filters.ImportBatchFilter
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(submitted_by=self.request.user)
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
# if user do not have library permission, we limit to their
|
||||
# own jobs
|
||||
if not self.request.user.has_permissions('library'):
|
||||
qs = qs.filter(submitted_by=self.request.user)
|
||||
return qs
|
||||
|
||||
|
||||
class ImportJobViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
|
@ -122,11 +114,22 @@ class ImportJobViewSet(
|
|||
queryset = (models.ImportJob.objects.all().select_related())
|
||||
serializer_class = serializers.ImportJobSerializer
|
||||
permission_classes = (HasUserPermission,)
|
||||
required_permissions = ['library']
|
||||
required_permissions = ['library', 'upload']
|
||||
permission_operator = 'or'
|
||||
filter_class = filters.ImportJobFilter
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
# if user do not have library permission, we limit to their
|
||||
# own jobs
|
||||
if not self.request.user.has_permissions('library'):
|
||||
qs = qs.filter(batch__submitted_by=self.request.user)
|
||||
return qs
|
||||
|
||||
@list_route(methods=['get'])
|
||||
def stats(self, request, *args, **kwargs):
|
||||
if not request.user.has_permissions('library'):
|
||||
return Response(status=403)
|
||||
qs = models.ImportJob.objects.all()
|
||||
filterset = filters.ImportJobFilter(request.GET, queryset=qs)
|
||||
qs = filterset.qs
|
||||
|
@ -160,20 +163,21 @@ class ImportJobViewSet(
|
|||
)
|
||||
|
||||
|
||||
class TrackViewSet(
|
||||
TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
|
||||
class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
A simple ViewSet for viewing and editing accounts.
|
||||
"""
|
||||
queryset = (models.Track.objects.all().for_nested_serialization())
|
||||
serializer_class = serializers.TrackSerializerNested
|
||||
serializer_class = serializers.TrackSerializer
|
||||
permission_classes = [ConditionalAuthentication]
|
||||
search_fields = ['title', 'artist__name']
|
||||
filter_class = filters.TrackFilter
|
||||
ordering_fields = (
|
||||
'creation_date',
|
||||
'title__unaccent',
|
||||
'album__title__unaccent',
|
||||
'artist__name__unaccent',
|
||||
'title',
|
||||
'album__title',
|
||||
'album__release_date',
|
||||
'position',
|
||||
'artist__name',
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
|
@ -238,8 +242,8 @@ 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).encode('utf-8')
|
||||
return path
|
||||
path = audio_file.replace(prefix, serve_path, 1)
|
||||
return path.encode('utf-8')
|
||||
|
||||
|
||||
def handle_serve(track_file):
|
||||
|
@ -370,10 +374,10 @@ class Search(views.APIView):
|
|||
def get(self, request, *args, **kwargs):
|
||||
query = request.GET['query']
|
||||
results = {
|
||||
'tags': serializers.TagSerializer(self.get_tags(query), many=True).data,
|
||||
'artists': serializers.ArtistSerializerNested(self.get_artists(query), many=True).data,
|
||||
'tracks': serializers.TrackSerializerNested(self.get_tracks(query), many=True).data,
|
||||
'albums': serializers.AlbumSerializerNested(self.get_albums(query), many=True).data,
|
||||
# 'tags': serializers.TagSerializer(self.get_tags(query), many=True).data,
|
||||
'artists': serializers.ArtistWithAlbumsSerializer(self.get_artists(query), many=True).data,
|
||||
'tracks': serializers.TrackSerializer(self.get_tracks(query), many=True).data,
|
||||
'albums': serializers.AlbumSerializer(self.get_albums(query), many=True).data,
|
||||
}
|
||||
return Response(results, status=200)
|
||||
|
||||
|
@ -387,14 +391,10 @@ class Search(views.APIView):
|
|||
return (
|
||||
models.Track.objects.all()
|
||||
.filter(query_obj)
|
||||
.select_related('album__artist')
|
||||
.prefetch_related(
|
||||
'tags',
|
||||
'artist__albums__tracks__tags',
|
||||
'files')
|
||||
.select_related('artist', 'album__artist')
|
||||
.prefetch_related('files')
|
||||
)[:self.max_results]
|
||||
|
||||
|
||||
def get_albums(self, query):
|
||||
search_fields = [
|
||||
'mbid',
|
||||
|
@ -406,27 +406,19 @@ class Search(views.APIView):
|
|||
.filter(query_obj)
|
||||
.select_related()
|
||||
.prefetch_related(
|
||||
'tracks__tags',
|
||||
'tracks__files',
|
||||
)
|
||||
)
|
||||
)[:self.max_results]
|
||||
|
||||
|
||||
def get_artists(self, query):
|
||||
search_fields = ['mbid', 'name__unaccent']
|
||||
query_obj = utils.get_query(query, search_fields)
|
||||
return (
|
||||
models.Artist.objects.all()
|
||||
.filter(query_obj)
|
||||
.select_related()
|
||||
.prefetch_related(
|
||||
'albums__tracks__tags',
|
||||
'albums__tracks__files',
|
||||
)
|
||||
|
||||
.with_albums()
|
||||
)[:self.max_results]
|
||||
|
||||
|
||||
def get_tags(self, query):
|
||||
search_fields = ['slug', 'name__unaccent']
|
||||
query_obj = utils.get_query(query, search_fields)
|
||||
|
@ -477,22 +469,6 @@ class SubmitViewSet(viewsets.ViewSet):
|
|||
data, request, batch=None, import_request=import_request)
|
||||
return Response(import_data)
|
||||
|
||||
@list_route(methods=['post'])
|
||||
@transaction.non_atomic_requests
|
||||
def federation(self, request, *args, **kwargs):
|
||||
serializer = serializers.SubmitFederationTracksSerializer(
|
||||
data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
batch = serializer.save(submitted_by=request.user)
|
||||
for job in batch.jobs.all():
|
||||
funkwhale_utils.on_commit(
|
||||
tasks.import_job_run.delay,
|
||||
import_job_id=job.pk,
|
||||
use_acoustid=False,
|
||||
)
|
||||
|
||||
return Response({'id': batch.id}, status=201)
|
||||
|
||||
@transaction.atomic
|
||||
def _import_album(self, data, request, batch=None, import_request=None):
|
||||
# we import the whole album here to prevent race conditions that occurs
|
||||
|
|
|
@ -5,13 +5,13 @@ from taggit.models import Tag
|
|||
|
||||
from funkwhale_api.common import preferences
|
||||
from funkwhale_api.music.models import Track
|
||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||
from funkwhale_api.music.serializers import TrackSerializer
|
||||
from funkwhale_api.users.serializers import UserBasicSerializer
|
||||
from . import models
|
||||
|
||||
|
||||
class PlaylistTrackSerializer(serializers.ModelSerializer):
|
||||
track = TrackSerializerNested()
|
||||
track = TrackSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.PlaylistTrack
|
||||
|
|
|
@ -54,13 +54,6 @@ class Command(BaseCommand):
|
|||
'import and not much disk space available.'
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-acoustid',
|
||||
action='store_true',
|
||||
dest='no_acoustid',
|
||||
default=False,
|
||||
help='Use this flag to completely bypass acoustid completely',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--noinput', '--no-input', action='store_false', dest='interactive',
|
||||
help="Do NOT prompt the user for input of any kind.",
|
||||
|
@ -118,7 +111,6 @@ class Command(BaseCommand):
|
|||
len(filtered['new'])))
|
||||
|
||||
self.stdout.write('Selected options: {}'.format(', '.join([
|
||||
'no acoustid' if options['no_acoustid'] else 'use acoustid',
|
||||
'in place' if options['in_place'] else 'copy music files',
|
||||
])))
|
||||
if len(filtered['new']) == 0:
|
||||
|
@ -201,4 +193,4 @@ class Command(BaseCommand):
|
|||
job.save()
|
||||
import_handler(
|
||||
import_job_id=job.pk,
|
||||
use_acoustid=not options['no_acoustid'])
|
||||
use_acoustid=False)
|
||||
|
|
|
@ -144,8 +144,8 @@ class ArtistFilter(RadioFilter):
|
|||
'name': 'ids',
|
||||
'type': 'list',
|
||||
'subtype': 'number',
|
||||
'autocomplete': reverse_lazy('api:v1:artists-search'),
|
||||
'autocomplete_qs': 'query={query}',
|
||||
'autocomplete': reverse_lazy('api:v1:artists-list'),
|
||||
'autocomplete_qs': 'q={query}',
|
||||
'autocomplete_fields': {'name': 'name', 'value': 'id'},
|
||||
'label': 'Artist',
|
||||
'placeholder': 'Select artists'
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||
from funkwhale_api.music.serializers import TrackSerializer
|
||||
from funkwhale_api.users.serializers import UserBasicSerializer
|
||||
|
||||
from . import filters
|
||||
|
@ -46,7 +46,7 @@ class RadioSessionTrackSerializerCreate(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class RadioSessionTrackSerializer(serializers.ModelSerializer):
|
||||
track = TrackSerializerNested()
|
||||
track = TrackSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.RadioSessionTrack
|
||||
|
|
|
@ -7,7 +7,7 @@ from rest_framework import status
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import detail_route, list_route
|
||||
|
||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||
from funkwhale_api.music.serializers import TrackSerializer
|
||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
||||
|
||||
from . import models
|
||||
|
@ -49,7 +49,7 @@ class RadioViewSet(
|
|||
|
||||
page = self.paginate_queryset(tracks)
|
||||
if page is not None:
|
||||
serializer = TrackSerializerNested(page, many=True)
|
||||
serializer = TrackSerializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@list_route(methods=['get'])
|
||||
|
@ -72,7 +72,7 @@ class RadioViewSet(
|
|||
results = filters.test(f)
|
||||
if results['candidates']['sample']:
|
||||
qs = results['candidates']['sample'].for_nested_serialization()
|
||||
results['candidates']['sample'] = TrackSerializerNested(
|
||||
results['candidates']['sample'] = TrackSerializer(
|
||||
qs, many=True).data
|
||||
data['filters'].append(results)
|
||||
|
||||
|
|
|
@ -3,15 +3,12 @@ from rest_framework import status
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import detail_route
|
||||
|
||||
from funkwhale_api.music.views import SearchMixin
|
||||
|
||||
from . import filters
|
||||
from . import models
|
||||
from . import serializers
|
||||
|
||||
|
||||
class ImportRequestViewSet(
|
||||
SearchMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
|
@ -22,7 +19,6 @@ class ImportRequestViewSet(
|
|||
models.ImportRequest.objects.all()
|
||||
.select_related()
|
||||
.order_by('-creation_date'))
|
||||
search_fields = ['artist_name', 'album_name', 'comment']
|
||||
filter_class = filters.ImportRequestFilter
|
||||
ordering_fields = ('id', 'artist_name', 'creation_date', 'status')
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ from django.db.models import functions, Count
|
|||
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.history import models as history_models
|
||||
from funkwhale_api.music import models as music_models
|
||||
|
||||
|
||||
|
@ -57,8 +58,10 @@ class GetArtistSerializer(serializers.Serializer):
|
|||
'name': album.title,
|
||||
'artist': artist.name,
|
||||
'created': album.creation_date,
|
||||
'songCount': len(album.tracks.all())
|
||||
'songCount': len(album.tracks.all()),
|
||||
}
|
||||
if album.cover:
|
||||
album_data['coverArt'] = 'al-{}'.format(album.id)
|
||||
if album.release_date:
|
||||
album_data['year'] = album.release_date.year
|
||||
payload['album'].append(album_data)
|
||||
|
@ -81,6 +84,8 @@ def get_track_data(album, track, tf):
|
|||
'artistId': album.artist.pk,
|
||||
'type': 'music',
|
||||
}
|
||||
if track.album.cover:
|
||||
data['coverArt'] = 'al-{}'.format(track.album.id)
|
||||
if tf.bitrate:
|
||||
data['bitrate'] = int(tf.bitrate/1000)
|
||||
if tf.size:
|
||||
|
@ -98,6 +103,9 @@ def get_album2_data(album):
|
|||
'artist': album.artist.name,
|
||||
'created': album.creation_date,
|
||||
}
|
||||
if album.cover:
|
||||
payload['coverArt'] = 'al-{}'.format(album.id)
|
||||
|
||||
try:
|
||||
payload['songCount'] = album._tracks_count
|
||||
except AttributeError:
|
||||
|
@ -221,3 +229,18 @@ def get_music_directory_data(artist):
|
|||
td['size'] = tf.size
|
||||
data['child'].append(td)
|
||||
return data
|
||||
|
||||
|
||||
class ScrobbleSerializer(serializers.Serializer):
|
||||
submission = serializers.BooleanField(default=True, required=False)
|
||||
id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=music_models.Track.objects.annotate(
|
||||
files_count=Count('files')
|
||||
).filter(files_count__gt=0)
|
||||
)
|
||||
|
||||
def create(self, data):
|
||||
return history_models.Listening.objects.create(
|
||||
user=self.context['user'],
|
||||
track=data['id'],
|
||||
)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from rest_framework import exceptions
|
||||
|
@ -10,6 +11,7 @@ from rest_framework import viewsets
|
|||
from rest_framework.decorators import list_route
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from funkwhale_api.activity import record
|
||||
from funkwhale_api.common import preferences
|
||||
from funkwhale_api.favorites.models import TrackFavorite
|
||||
from funkwhale_api.music import models as music_models
|
||||
|
@ -459,7 +461,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
|
|||
'code': 10,
|
||||
'message': 'Playlist ID or name must be specified.'
|
||||
}
|
||||
}, data)
|
||||
})
|
||||
|
||||
playlist = request.user.playlists.create(
|
||||
name=name
|
||||
|
@ -503,3 +505,71 @@ class SubsonicViewSet(viewsets.GenericViewSet):
|
|||
}
|
||||
}
|
||||
return response.Response(data)
|
||||
|
||||
@list_route(
|
||||
methods=['get', 'post'],
|
||||
url_name='get_cover_art',
|
||||
url_path='getCoverArt')
|
||||
def get_cover_art(self, request, *args, **kwargs):
|
||||
data = request.GET or request.POST
|
||||
id = data.get('id', '')
|
||||
if not id:
|
||||
return response.Response({
|
||||
'error': {
|
||||
'code': 10,
|
||||
'message': 'cover art ID must be specified.'
|
||||
}
|
||||
})
|
||||
|
||||
if id.startswith('al-'):
|
||||
try:
|
||||
album_id = int(id.replace('al-', ''))
|
||||
album = music_models.Album.objects.exclude(
|
||||
cover__isnull=True
|
||||
).exclude(cover='').get(pk=album_id)
|
||||
except (TypeError, ValueError, music_models.Album.DoesNotExist):
|
||||
return response.Response({
|
||||
'error': {
|
||||
'code': 70,
|
||||
'message': 'cover art not found.'
|
||||
}
|
||||
})
|
||||
cover = album.cover
|
||||
else:
|
||||
return response.Response({
|
||||
'error': {
|
||||
'code': 70,
|
||||
'message': 'cover art not found.'
|
||||
}
|
||||
})
|
||||
|
||||
mapping = {
|
||||
'nginx': 'X-Accel-Redirect',
|
||||
'apache2': 'X-Sendfile',
|
||||
}
|
||||
path = music_views.get_file_path(cover)
|
||||
file_header = mapping[settings.REVERSE_PROXY_TYPE]
|
||||
# let the proxy set the content-type
|
||||
r = response.Response({}, content_type='')
|
||||
r[file_header] = path
|
||||
return r
|
||||
|
||||
@list_route(
|
||||
methods=['get', 'post'],
|
||||
url_name='scrobble',
|
||||
url_path='scrobble')
|
||||
def scrobble(self, request, *args, **kwargs):
|
||||
data = request.GET or request.POST
|
||||
serializer = serializers.ScrobbleSerializer(
|
||||
data=data, context={'user': request.user})
|
||||
if not serializer.is_valid():
|
||||
return response.Response({
|
||||
'error': {
|
||||
'code': 0,
|
||||
'message': 'Invalid payload'
|
||||
}
|
||||
})
|
||||
if serializer.validated_data['submission']:
|
||||
l = serializer.save()
|
||||
record.send(l)
|
||||
return response.Response({})
|
||||
|
|
|
@ -62,6 +62,7 @@ class UserAdmin(AuthUserAdmin):
|
|||
'is_active',
|
||||
'is_staff',
|
||||
'is_superuser',
|
||||
'permission_upload',
|
||||
'permission_library',
|
||||
'permission_settings',
|
||||
'permission_federation')}),
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
from dynamic_preferences import types
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
from funkwhale_api.common import preferences as common_preferences
|
||||
|
||||
from . import models
|
||||
|
||||
users = types.Section('users')
|
||||
|
||||
|
||||
|
@ -14,3 +18,23 @@ class RegistrationEnabled(types.BooleanPreference):
|
|||
help_text = (
|
||||
'When enabled, new users will be able to register on this instance.'
|
||||
)
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class DefaultPermissions(common_preferences.StringListPreference):
|
||||
show_in_api = True
|
||||
section = users
|
||||
name = 'default_permissions'
|
||||
default = []
|
||||
verbose_name = 'Default permissions'
|
||||
help_text = (
|
||||
'A list of default preferences to give to all registered users.'
|
||||
)
|
||||
choices = [
|
||||
(k, c['label'])
|
||||
for k, c in models.PERMISSIONS_CONFIGURATION.items()
|
||||
]
|
||||
field_kwargs = {
|
||||
'choices': choices,
|
||||
'required': False,
|
||||
}
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 2.0.4 on 2018-05-24 20:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0006_auto_20180517_2324'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='permission_upload',
|
||||
field=models.BooleanField(default=False, verbose_name='Upload new content to the library'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='permission_federation',
|
||||
field=models.BooleanField(default=False, help_text='Follow other instances, accept/deny library follow requests...', verbose_name='Manage library federation'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='permission_library',
|
||||
field=models.BooleanField(default=False, help_text='Manage library', verbose_name='Manage library'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='permission_settings',
|
||||
field=models.BooleanField(default=False, verbose_name='Manage instance-level settings'),
|
||||
),
|
||||
]
|
|
@ -13,17 +13,33 @@ from django.utils.encoding import python_2_unicode_compatible
|
|||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from funkwhale_api.common import fields
|
||||
from funkwhale_api.common import preferences
|
||||
|
||||
|
||||
def get_token():
|
||||
return binascii.b2a_hex(os.urandom(15)).decode('utf-8')
|
||||
|
||||
|
||||
PERMISSIONS = [
|
||||
'federation',
|
||||
'library',
|
||||
'settings',
|
||||
]
|
||||
PERMISSIONS_CONFIGURATION = {
|
||||
'federation': {
|
||||
'label': 'Manage library federation',
|
||||
'help_text': 'Follow other instances, accept/deny library follow requests...',
|
||||
},
|
||||
'library': {
|
||||
'label': 'Manage library',
|
||||
'help_text': 'Manage library, delete files, tracks, artists, albums...',
|
||||
},
|
||||
'settings': {
|
||||
'label': 'Manage instance-level settings',
|
||||
'help_text': '',
|
||||
},
|
||||
'upload': {
|
||||
'label': 'Upload new content to the library',
|
||||
'help_text': '',
|
||||
},
|
||||
}
|
||||
|
||||
PERMISSIONS = sorted(PERMISSIONS_CONFIGURATION.keys())
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
|
@ -47,30 +63,43 @@ class User(AbstractUser):
|
|||
|
||||
# permissions
|
||||
permission_federation = models.BooleanField(
|
||||
'Manage library federation',
|
||||
help_text='Follow other instances, accept/deny library follow requests...',
|
||||
PERMISSIONS_CONFIGURATION['federation']['label'],
|
||||
help_text=PERMISSIONS_CONFIGURATION['federation']['help_text'],
|
||||
default=False)
|
||||
permission_library = models.BooleanField(
|
||||
'Manage library',
|
||||
help_text='Import new content, manage existing content',
|
||||
PERMISSIONS_CONFIGURATION['library']['label'],
|
||||
help_text=PERMISSIONS_CONFIGURATION['library']['help_text'],
|
||||
default=False)
|
||||
permission_settings = models.BooleanField(
|
||||
'Manage instance-level settings',
|
||||
PERMISSIONS_CONFIGURATION['settings']['label'],
|
||||
help_text=PERMISSIONS_CONFIGURATION['settings']['help_text'],
|
||||
default=False)
|
||||
permission_upload = models.BooleanField(
|
||||
PERMISSIONS_CONFIGURATION['upload']['label'],
|
||||
help_text=PERMISSIONS_CONFIGURATION['upload']['help_text'],
|
||||
default=False)
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
def get_permissions(self):
|
||||
defaults = preferences.get('users__default_permissions')
|
||||
perms = {}
|
||||
for p in PERMISSIONS:
|
||||
v = self.is_superuser or getattr(self, 'permission_{}'.format(p))
|
||||
v = (
|
||||
self.is_superuser or
|
||||
getattr(self, 'permission_{}'.format(p)) or
|
||||
p in defaults
|
||||
)
|
||||
perms[p] = v
|
||||
return perms
|
||||
|
||||
def has_permissions(self, *perms):
|
||||
def has_permissions(self, *perms, operator='and'):
|
||||
if operator not in ['and', 'or']:
|
||||
raise ValueError('Invalid operator {}'.format(operator))
|
||||
permissions = self.get_permissions()
|
||||
return all([permissions[p] for p in perms])
|
||||
checker = all if operator == 'and' else any
|
||||
return checker([permissions[p] for p in perms])
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('users:detail', kwargs={'username': self.username})
|
||||
|
|
|
@ -16,4 +16,6 @@ class HasUserPermission(BasePermission):
|
|||
return False
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
return request.user.has_permissions(*view.required_permissions)
|
||||
operator = getattr(view, 'permission_operator', 'and')
|
||||
return request.user.has_permissions(
|
||||
*view.required_permissions, operator=operator)
|
||||
|
|
|
@ -10,7 +10,7 @@ Pillow>=4.3,<4.4
|
|||
|
||||
# For user registration, either via email or social
|
||||
# Well-built with regular release cycles!
|
||||
django-allauth>=0.34,<0.35
|
||||
django-allauth>=0.36,<0.37
|
||||
|
||||
|
||||
# Python-PostgreSQL Database Adapter
|
||||
|
@ -65,3 +65,4 @@ cryptography>=2,<3
|
|||
# requests-http-signature==0.0.3
|
||||
# clone until the branch is merged and released upstream
|
||||
git+https://github.com/EliotBerriot/requests-http-signature.git@signature-header-support
|
||||
django-cleanup==2.1.0
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import pytest
|
||||
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
from funkwhale_api.common import preferences as common_preferences
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def string_list_pref(preferences):
|
||||
|
||||
@global_preferences_registry.register
|
||||
class P(common_preferences.StringListPreference):
|
||||
default = ['hello']
|
||||
section = 'test'
|
||||
name = 'string_list'
|
||||
yield
|
||||
del global_preferences_registry['test']['string_list']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('input,output', [
|
||||
(['a', 'b', 'c'], 'a,b,c'),
|
||||
(['a', 'c', 'b'], 'a,b,c'),
|
||||
(('a', 'c', 'b'), 'a,b,c'),
|
||||
([], None),
|
||||
])
|
||||
def test_string_list_serializer_to_db(input, output):
|
||||
s = common_preferences.StringListSerializer.to_db(input) == output
|
||||
|
||||
|
||||
@pytest.mark.parametrize('input,output', [
|
||||
('a,b,c', ['a', 'b', 'c'], ),
|
||||
(None, []),
|
||||
('', []),
|
||||
])
|
||||
def test_string_list_serializer_to_python(input, output):
|
||||
s = common_preferences.StringListSerializer.to_python(input) == output
|
||||
|
||||
|
||||
def test_string_list_pref_default(string_list_pref, preferences):
|
||||
assert preferences['test__string_list'] == ['hello']
|
||||
|
||||
|
||||
def test_string_list_pref_set(string_list_pref, preferences):
|
||||
preferences['test__string_list'] = ['world', 'hello']
|
||||
assert preferences['test__string_list'] == ['hello', 'world']
|
|
@ -0,0 +1,136 @@
|
|||
import django_filters
|
||||
|
||||
from funkwhale_api.common import serializers
|
||||
from funkwhale_api.users import models
|
||||
|
||||
|
||||
class TestActionFilterSet(django_filters.FilterSet):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ['is_active']
|
||||
|
||||
|
||||
class TestSerializer(serializers.ActionSerializer):
|
||||
actions = ['test']
|
||||
filterset_class = TestActionFilterSet
|
||||
|
||||
def handle_test(self, objects):
|
||||
return {'hello': 'world'}
|
||||
|
||||
|
||||
class TestDangerousSerializer(serializers.ActionSerializer):
|
||||
actions = ['test', 'test_dangerous']
|
||||
dangerous_actions = ['test_dangerous']
|
||||
|
||||
def handle_test(self, objects):
|
||||
pass
|
||||
|
||||
def handle_test_dangerous(self, objects):
|
||||
pass
|
||||
|
||||
|
||||
def test_action_serializer_validates_action():
|
||||
data = {'objects': 'all', 'action': 'nope'}
|
||||
serializer = TestSerializer(data, queryset=models.User.objects.none())
|
||||
|
||||
assert serializer.is_valid() is False
|
||||
assert 'action' in serializer.errors
|
||||
|
||||
|
||||
def test_action_serializer_validates_objects():
|
||||
data = {'objects': 'nope', 'action': 'test'}
|
||||
serializer = TestSerializer(data, queryset=models.User.objects.none())
|
||||
|
||||
assert serializer.is_valid() is False
|
||||
assert 'objects' in serializer.errors
|
||||
|
||||
|
||||
def test_action_serializers_objects_clean_ids(factories):
|
||||
user1 = factories['users.User']()
|
||||
user2 = factories['users.User']()
|
||||
|
||||
data = {'objects': [user1.pk], 'action': 'test'}
|
||||
serializer = TestSerializer(data, queryset=models.User.objects.all())
|
||||
|
||||
assert serializer.is_valid() is True
|
||||
assert list(serializer.validated_data['objects']) == [user1]
|
||||
|
||||
|
||||
def test_action_serializers_objects_clean_all(factories):
|
||||
user1 = factories['users.User']()
|
||||
user2 = factories['users.User']()
|
||||
|
||||
data = {'objects': 'all', 'action': 'test'}
|
||||
serializer = TestSerializer(data, queryset=models.User.objects.all())
|
||||
|
||||
assert serializer.is_valid() is True
|
||||
assert list(serializer.validated_data['objects']) == [user1, user2]
|
||||
|
||||
|
||||
def test_action_serializers_save(factories, mocker):
|
||||
handler = mocker.spy(TestSerializer, 'handle_test')
|
||||
user1 = factories['users.User']()
|
||||
user2 = factories['users.User']()
|
||||
|
||||
data = {'objects': 'all', 'action': 'test'}
|
||||
serializer = TestSerializer(data, queryset=models.User.objects.all())
|
||||
|
||||
assert serializer.is_valid() is True
|
||||
result = serializer.save()
|
||||
assert result == {
|
||||
'updated': 2,
|
||||
'action': 'test',
|
||||
'result': {'hello': 'world'},
|
||||
}
|
||||
handler.assert_called_once()
|
||||
|
||||
|
||||
def test_action_serializers_filterset(factories):
|
||||
user1 = factories['users.User'](is_active=False)
|
||||
user2 = factories['users.User'](is_active=True)
|
||||
|
||||
data = {
|
||||
'objects': 'all',
|
||||
'action': 'test',
|
||||
'filters': {'is_active': True},
|
||||
}
|
||||
serializer = TestSerializer(data, queryset=models.User.objects.all())
|
||||
|
||||
assert serializer.is_valid() is True
|
||||
assert list(serializer.validated_data['objects']) == [user2]
|
||||
|
||||
|
||||
def test_action_serializers_validates_at_least_one_object():
|
||||
data = {
|
||||
'objects': 'all',
|
||||
'action': 'test',
|
||||
}
|
||||
serializer = TestSerializer(data, queryset=models.User.objects.none())
|
||||
|
||||
assert serializer.is_valid() is False
|
||||
assert 'non_field_errors' in serializer.errors
|
||||
|
||||
|
||||
def test_dangerous_actions_refuses_all(factories):
|
||||
factories['users.User']()
|
||||
data = {
|
||||
'objects': 'all',
|
||||
'action': 'test_dangerous',
|
||||
}
|
||||
serializer = TestDangerousSerializer(
|
||||
data, queryset=models.User.objects.all())
|
||||
|
||||
assert serializer.is_valid() is False
|
||||
assert 'non_field_errors' in serializer.errors
|
||||
|
||||
|
||||
def test_dangerous_actions_refuses_not_listed(factories):
|
||||
factories['users.User']()
|
||||
data = {
|
||||
'objects': 'all',
|
||||
'action': 'test',
|
||||
}
|
||||
serializer = TestDangerousSerializer(
|
||||
data, queryset=models.User.objects.all())
|
||||
|
||||
assert serializer.is_valid() is True
|
|
@ -1,3 +1,4 @@
|
|||
import datetime
|
||||
import factory
|
||||
import pytest
|
||||
import requests_mock
|
||||
|
@ -10,6 +11,7 @@ from django.test import client
|
|||
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
from rest_framework import fields as rest_fields
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
|
@ -229,7 +231,21 @@ def authenticated_actor(factories, mocker):
|
|||
|
||||
@pytest.fixture
|
||||
def assert_user_permission():
|
||||
def inner(view, permissions):
|
||||
def inner(view, permissions, operator='and'):
|
||||
assert HasUserPermission in view.permission_classes
|
||||
assert getattr(view, 'permission_operator', 'and') == operator
|
||||
assert set(view.required_permissions) == set(permissions)
|
||||
return inner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def to_api_date():
|
||||
def inner(value):
|
||||
if isinstance(value, datetime.datetime):
|
||||
f = rest_fields.DateTimeField()
|
||||
return f.to_representation(value)
|
||||
if isinstance(value, datetime.date):
|
||||
f = rest_fields.DateField()
|
||||
return f.to_representation(value)
|
||||
raise ValueError('Invalid value: {}'.format(value))
|
||||
return inner
|
||||
|
|
|
@ -699,3 +699,26 @@ def test_api_library_create_serializer_save(factories, r_mock):
|
|||
assert library.tracks_count == 10
|
||||
assert library.actor == actor
|
||||
assert library.follow == follow
|
||||
|
||||
|
||||
def test_tapi_library_track_serializer_not_imported(factories):
|
||||
lt = factories['federation.LibraryTrack']()
|
||||
serializer = serializers.APILibraryTrackSerializer(lt)
|
||||
|
||||
assert serializer.get_status(lt) == 'not_imported'
|
||||
|
||||
|
||||
def test_tapi_library_track_serializer_imported(factories):
|
||||
tf = factories['music.TrackFile'](federation=True)
|
||||
lt = tf.library_track
|
||||
serializer = serializers.APILibraryTrackSerializer(lt)
|
||||
|
||||
assert serializer.get_status(lt) == 'imported'
|
||||
|
||||
|
||||
def test_tapi_library_track_serializer_import_pending(factories):
|
||||
job = factories['music.ImportJob'](federation=True, status='pending')
|
||||
lt = job.library_track
|
||||
serializer = serializers.APILibraryTrackSerializer(lt)
|
||||
|
||||
assert serializer.get_status(lt) == 'import_pending'
|
||||
|
|
|
@ -418,3 +418,39 @@ def test_can_filter_pending_follows(factories, superuser_api_client):
|
|||
|
||||
assert response.status_code == 200
|
||||
assert len(response.data['results']) == 0
|
||||
|
||||
|
||||
def test_library_track_action_import(
|
||||
factories, superuser_api_client, mocker):
|
||||
lt1 = factories['federation.LibraryTrack']()
|
||||
lt2 = factories['federation.LibraryTrack'](library=lt1.library)
|
||||
lt3 = factories['federation.LibraryTrack']()
|
||||
lt4 = factories['federation.LibraryTrack'](library=lt3.library)
|
||||
mocked_run = mocker.patch(
|
||||
'funkwhale_api.music.tasks.import_batch_run.delay')
|
||||
|
||||
payload = {
|
||||
'objects': 'all',
|
||||
'action': 'import',
|
||||
'filters': {
|
||||
'library': lt1.library.uuid
|
||||
}
|
||||
}
|
||||
url = reverse('api:v1:federation:library-tracks-action')
|
||||
response = superuser_api_client.post(url, payload, format='json')
|
||||
batch = superuser_api_client.user.imports.latest('id')
|
||||
expected = {
|
||||
'updated': 2,
|
||||
'action': 'import',
|
||||
'result': {
|
||||
'batch': {'id': batch.pk}
|
||||
}
|
||||
}
|
||||
|
||||
imported_lts = [lt1, lt2]
|
||||
assert response.status_code == 200
|
||||
assert response.data == expected
|
||||
assert batch.jobs.count() == 2
|
||||
for i, job in enumerate(batch.jobs.all()):
|
||||
assert job.library_track == imported_lts[i]
|
||||
mocked_run.assert_called_once_with(import_batch_id=batch.pk)
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
from funkwhale_api.manage import serializers
|
||||
|
||||
|
||||
def test_manage_track_file_action_delete(factories):
|
||||
tfs = factories['music.TrackFile'](size=5)
|
||||
s = serializers.ManageTrackFileActionSerializer(queryset=None)
|
||||
|
||||
s.handle_delete(tfs.__class__.objects.all())
|
||||
|
||||
assert tfs.__class__.objects.count() == 0
|
|
@ -0,0 +1,26 @@
|
|||
import pytest
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from funkwhale_api.manage import serializers
|
||||
from funkwhale_api.manage import views
|
||||
|
||||
|
||||
@pytest.mark.parametrize('view,permissions,operator', [
|
||||
(views.ManageTrackFileViewSet, ['library'], 'and'),
|
||||
])
|
||||
def test_permissions(assert_user_permission, view, permissions, operator):
|
||||
assert_user_permission(view, permissions, operator)
|
||||
|
||||
|
||||
def test_track_file_view(factories, superuser_api_client):
|
||||
tfs = factories['music.TrackFile'].create_batch(size=5)
|
||||
qs = tfs[0].__class__.objects.order_by('-creation_date')
|
||||
url = reverse('api:v1:manage:library:track-files-list')
|
||||
|
||||
response = superuser_api_client.get(url, {'sort': '-creation_date'})
|
||||
expected = serializers.ManageTrackFileSerializer(
|
||||
qs, many=True, context={'request': response.wsgi_request}).data
|
||||
|
||||
assert response.data['count'] == len(tfs)
|
||||
assert response.data['results'] == expected
|
Binary file not shown.
After Width: | Height: | Size: 107 B |
Binary file not shown.
After Width: | Height: | Size: 379 B |
Binary file not shown.
Binary file not shown.
|
@ -223,41 +223,6 @@ def test_user_can_create_import_job_with_file(
|
|||
import_job_id=job.pk)
|
||||
|
||||
|
||||
def test_can_search_artist(factories, logged_in_client):
|
||||
artist1 = factories['music.Artist']()
|
||||
artist2 = factories['music.Artist']()
|
||||
expected = [serializers.ArtistSerializerNested(artist1).data]
|
||||
url = reverse('api:v1:artists-search')
|
||||
response = logged_in_client.get(url, {'query': artist1.name})
|
||||
assert response.data == expected
|
||||
|
||||
|
||||
def test_can_search_artist_by_name_start(factories, logged_in_client):
|
||||
artist1 = factories['music.Artist'](name='alpha')
|
||||
artist2 = factories['music.Artist'](name='beta')
|
||||
expected = {
|
||||
'next': None,
|
||||
'previous': None,
|
||||
'count': 1,
|
||||
'results': [serializers.ArtistSerializerNested(artist1).data]
|
||||
}
|
||||
url = reverse('api:v1:artists-list')
|
||||
response = logged_in_client.get(url, {'name__startswith': 'a'})
|
||||
|
||||
assert expected == response.data
|
||||
|
||||
|
||||
def test_can_search_tracks(factories, logged_in_client):
|
||||
track1 = factories['music.Track'](title="test track 1")
|
||||
track2 = factories['music.Track']()
|
||||
query = 'test track 1'
|
||||
expected = [serializers.TrackSerializerNested(track1).data]
|
||||
url = reverse('api:v1:tracks-search')
|
||||
response = logged_in_client.get(url, {'query': query})
|
||||
|
||||
assert expected == response.data
|
||||
|
||||
|
||||
@pytest.mark.parametrize('route,method', [
|
||||
('api:v1:tags-list', 'get'),
|
||||
('api:v1:tracks-list', 'get'),
|
||||
|
|
|
@ -24,13 +24,29 @@ def test_can_get_metadata_from_ogg_file(field, value):
|
|||
|
||||
assert data.get(field) == value
|
||||
|
||||
@pytest.mark.parametrize('field,value', [
|
||||
('title', 'Drei Kreuze (dass wir hier sind)'),
|
||||
('artist', 'Die Toten Hosen'),
|
||||
('album', 'Ballast der Republik'),
|
||||
('date', datetime.date(2012, 5, 4)),
|
||||
('track_number', 1),
|
||||
('musicbrainz_albumid', uuid.UUID('1f0441ad-e609-446d-b355-809c445773cf')),
|
||||
('musicbrainz_recordingid', uuid.UUID('124d0150-8627-46bc-bc14-789a3bc960c8')),
|
||||
('musicbrainz_artistid', uuid.UUID('c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1')),
|
||||
])
|
||||
def test_can_get_metadata_from_ogg_theora_file(field, value):
|
||||
path = os.path.join(DATA_DIR, 'test_theora.ogg')
|
||||
data = metadata.Metadata(path)
|
||||
|
||||
assert data.get(field) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize('field,value', [
|
||||
('title', 'Bend'),
|
||||
('artist', 'Binärpilot'),
|
||||
('artist', 'Bindrpilot'),
|
||||
('album', 'You Can\'t Stop Da Funk'),
|
||||
('date', datetime.date(2006, 2, 7)),
|
||||
('track_number', 1),
|
||||
('track_number', 2),
|
||||
('musicbrainz_albumid', uuid.UUID('ce40cdb1-a562-4fd8-a269-9269f98d4124')),
|
||||
('musicbrainz_recordingid', uuid.UUID('f269d497-1cc0-4ae4-a0c4-157ec7d73fcb')),
|
||||
('musicbrainz_artistid', uuid.UUID('9c6bddde-6228-4d9f-ad0d-03f6fcb19e13')),
|
||||
|
@ -42,6 +58,20 @@ def test_can_get_metadata_from_id3_mp3_file(field, value):
|
|||
assert data.get(field) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', ['test.mp3', 'sample.flac'])
|
||||
def test_can_get_pictures(name):
|
||||
path = os.path.join(DATA_DIR, name)
|
||||
data = metadata.Metadata(path)
|
||||
|
||||
pictures = data.get('pictures')
|
||||
assert len(pictures) == 1
|
||||
cover_data = data.get_picture('cover_front')
|
||||
assert cover_data['mimetype'].startswith('image/')
|
||||
assert len(cover_data['content']) > 0
|
||||
assert type(cover_data['content']) == bytes
|
||||
assert type(cover_data['description']) == str
|
||||
|
||||
|
||||
@pytest.mark.parametrize('field,value', [
|
||||
('title', '999,999'),
|
||||
('artist', 'Nine Inch Nails'),
|
||||
|
@ -57,3 +87,11 @@ def test_can_get_metadata_from_flac_file(field, value):
|
|||
data = metadata.Metadata(path)
|
||||
|
||||
assert data.get(field) == value
|
||||
|
||||
|
||||
def test_can_get_metadata_from_flac_file_not_crash_if_empty():
|
||||
path = os.path.join(DATA_DIR, 'sample.flac')
|
||||
data = metadata.Metadata(path)
|
||||
|
||||
with pytest.raises(metadata.TagNotFound):
|
||||
data.get('test')
|
||||
|
|
|
@ -110,3 +110,11 @@ def test_track_get_file_size_in_place(factories):
|
|||
in_place=True, source='file://{}'.format(path))
|
||||
|
||||
assert tf.get_file_size() == 297745
|
||||
|
||||
|
||||
def test_album_get_image_content(factories):
|
||||
album = factories['music.Album']()
|
||||
album.get_image(data={'content': b'test', 'mimetype':'image/jpeg'})
|
||||
album.refresh_from_db()
|
||||
|
||||
assert album.cover.read() == b'test'
|
||||
|
|
|
@ -4,26 +4,17 @@ from funkwhale_api.federation import actors
|
|||
from funkwhale_api.music import permissions
|
||||
|
||||
|
||||
def test_list_permission_no_protect(anonymous_user, api_request, settings):
|
||||
settings.PROTECT_AUDIO_FILES = False
|
||||
def test_list_permission_no_protect(preferences, anonymous_user, api_request):
|
||||
preferences['common__api_authentication_required'] = False
|
||||
view = APIView.as_view()
|
||||
permission = permissions.Listen()
|
||||
request = api_request.get('/')
|
||||
assert permission.has_permission(request, view) is True
|
||||
|
||||
|
||||
def test_list_permission_protect_anonymous(
|
||||
db, anonymous_user, api_request, settings):
|
||||
settings.PROTECT_AUDIO_FILES = True
|
||||
view = APIView.as_view()
|
||||
permission = permissions.Listen()
|
||||
request = api_request.get('/')
|
||||
assert permission.has_permission(request, view) is False
|
||||
|
||||
|
||||
def test_list_permission_protect_authenticated(
|
||||
factories, api_request, settings):
|
||||
settings.PROTECT_AUDIO_FILES = True
|
||||
factories, api_request, preferences):
|
||||
preferences['common__api_authentication_required'] = True
|
||||
user = factories['users.User']()
|
||||
view = APIView.as_view()
|
||||
permission = permissions.Listen()
|
||||
|
@ -33,8 +24,8 @@ def test_list_permission_protect_authenticated(
|
|||
|
||||
|
||||
def test_list_permission_protect_not_following_actor(
|
||||
factories, api_request, settings):
|
||||
settings.PROTECT_AUDIO_FILES = True
|
||||
factories, api_request, preferences):
|
||||
preferences['common__api_authentication_required'] = True
|
||||
actor = factories['federation.Actor']()
|
||||
view = APIView.as_view()
|
||||
permission = permissions.Listen()
|
||||
|
@ -44,8 +35,8 @@ def test_list_permission_protect_not_following_actor(
|
|||
|
||||
|
||||
def test_list_permission_protect_following_actor(
|
||||
factories, api_request, settings):
|
||||
settings.PROTECT_AUDIO_FILES = True
|
||||
factories, api_request, preferences):
|
||||
preferences['common__api_authentication_required'] = True
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
follow = factories['federation.Follow'](
|
||||
approved=True, target=library_actor)
|
||||
|
@ -58,8 +49,8 @@ def test_list_permission_protect_following_actor(
|
|||
|
||||
|
||||
def test_list_permission_protect_following_actor_not_approved(
|
||||
factories, api_request, settings):
|
||||
settings.PROTECT_AUDIO_FILES = True
|
||||
factories, api_request, preferences):
|
||||
preferences['common__api_authentication_required'] = True
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
follow = factories['federation.Follow'](
|
||||
approved=False, target=library_actor)
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
from funkwhale_api.music import serializers
|
||||
|
||||
|
||||
def test_artist_album_serializer(factories, to_api_date):
|
||||
track = factories['music.Track']()
|
||||
album = track.album
|
||||
album = album.__class__.objects.with_tracks_count().get(pk=album.pk)
|
||||
expected = {
|
||||
'id': album.id,
|
||||
'mbid': str(album.mbid),
|
||||
'title': album.title,
|
||||
'artist': album.artist.id,
|
||||
'creation_date': to_api_date(album.creation_date),
|
||||
'tracks_count': 1,
|
||||
'cover': album.cover.url,
|
||||
'release_date': to_api_date(album.release_date),
|
||||
}
|
||||
serializer = serializers.ArtistAlbumSerializer(album)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_artist_with_albums_serializer(factories, to_api_date):
|
||||
track = factories['music.Track']()
|
||||
artist = track.artist
|
||||
artist = artist.__class__.objects.with_albums().get(pk=artist.pk)
|
||||
album = list(artist.albums.all())[0]
|
||||
|
||||
expected = {
|
||||
'id': artist.id,
|
||||
'mbid': str(artist.mbid),
|
||||
'name': artist.name,
|
||||
'creation_date': to_api_date(artist.creation_date),
|
||||
'albums': [
|
||||
serializers.ArtistAlbumSerializer(album).data
|
||||
]
|
||||
}
|
||||
serializer = serializers.ArtistWithAlbumsSerializer(artist)
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_album_track_serializer(factories, to_api_date):
|
||||
tf = factories['music.TrackFile']()
|
||||
track = tf.track
|
||||
|
||||
expected = {
|
||||
'id': track.id,
|
||||
'artist': track.artist.id,
|
||||
'album': track.album.id,
|
||||
'mbid': str(track.mbid),
|
||||
'title': track.title,
|
||||
'position': track.position,
|
||||
'creation_date': to_api_date(track.creation_date),
|
||||
'files': [
|
||||
serializers.TrackFileSerializer(tf).data
|
||||
]
|
||||
}
|
||||
serializer = serializers.AlbumTrackSerializer(track)
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_track_file_serializer(factories, to_api_date):
|
||||
tf = factories['music.TrackFile']()
|
||||
|
||||
expected = {
|
||||
'id': tf.id,
|
||||
'path': tf.path,
|
||||
'source': tf.source,
|
||||
'filename': tf.filename,
|
||||
'mimetype': tf.mimetype,
|
||||
'track': tf.track.pk,
|
||||
'duration': tf.duration,
|
||||
'mimetype': tf.mimetype,
|
||||
'bitrate': tf.bitrate,
|
||||
'size': tf.size,
|
||||
}
|
||||
serializer = serializers.TrackFileSerializer(tf)
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_album_serializer(factories, to_api_date):
|
||||
track1 = factories['music.Track'](position=2)
|
||||
track2 = factories['music.Track'](position=1, album=track1.album)
|
||||
album = track1.album
|
||||
expected = {
|
||||
'id': album.id,
|
||||
'mbid': str(album.mbid),
|
||||
'title': album.title,
|
||||
'artist': serializers.ArtistSimpleSerializer(album.artist).data,
|
||||
'creation_date': to_api_date(album.creation_date),
|
||||
'cover': album.cover.url,
|
||||
'release_date': to_api_date(album.release_date),
|
||||
'tracks': serializers.AlbumTrackSerializer(
|
||||
[track2, track1],
|
||||
many=True
|
||||
).data
|
||||
}
|
||||
serializer = serializers.AlbumSerializer(album)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_track_serializer(factories, to_api_date):
|
||||
tf = factories['music.TrackFile']()
|
||||
track = tf.track
|
||||
|
||||
expected = {
|
||||
'id': track.id,
|
||||
'artist': serializers.ArtistSimpleSerializer(track.artist).data,
|
||||
'album': serializers.TrackAlbumSerializer(track.album).data,
|
||||
'mbid': str(track.mbid),
|
||||
'title': track.title,
|
||||
'position': track.position,
|
||||
'creation_date': to_api_date(track.creation_date),
|
||||
'lyrics': track.get_lyrics_url(),
|
||||
'files': [
|
||||
serializers.TrackFileSerializer(tf).data
|
||||
]
|
||||
}
|
||||
serializer = serializers.TrackSerializer(track)
|
||||
assert serializer.data == expected
|
|
@ -47,6 +47,16 @@ def test_set_acoustid_on_track_file_required_high_score(factories, mocker):
|
|||
assert track_file.acoustid_track_id is None
|
||||
|
||||
|
||||
def test_import_batch_run(factories, mocker):
|
||||
job = factories['music.ImportJob']()
|
||||
mocked_job_run = mocker.patch(
|
||||
'funkwhale_api.music.tasks.import_job_run.delay')
|
||||
tasks.import_batch_run(import_batch_id=job.batch.pk)
|
||||
|
||||
mocked_job_run.assert_called_once_with(import_job_id=job.pk)
|
||||
|
||||
|
||||
@pytest.mark.skip('Acoustid is disabled')
|
||||
def test_import_job_can_run_with_file_and_acoustid(
|
||||
artists, albums, tracks, preferences, factories, mocker):
|
||||
preferences['providers_acoustid__api_key'] = 'test'
|
||||
|
@ -105,7 +115,7 @@ def test_run_import_skipping_accoustid(factories, mocker):
|
|||
def test__do_import_skipping_accoustid(factories, mocker):
|
||||
t = factories['music.Track']()
|
||||
m = mocker.patch(
|
||||
'funkwhale_api.music.tasks.import_track_data_from_path',
|
||||
'funkwhale_api.providers.audiofile.tasks.import_track_data_from_path',
|
||||
return_value=t)
|
||||
path = os.path.join(DATA_DIR, 'test.ogg')
|
||||
job = factories['music.FileImportJob'](
|
||||
|
@ -121,7 +131,7 @@ def test__do_import_skipping_accoustid_if_no_key(
|
|||
preferences['providers_acoustid__api_key'] = ''
|
||||
t = factories['music.Track']()
|
||||
m = mocker.patch(
|
||||
'funkwhale_api.music.tasks.import_track_data_from_path',
|
||||
'funkwhale_api.providers.audiofile.tasks.import_track_data_from_path',
|
||||
return_value=t)
|
||||
path = os.path.join(DATA_DIR, 'test.ogg')
|
||||
job = factories['music.FileImportJob'](
|
||||
|
@ -132,32 +142,14 @@ def test__do_import_skipping_accoustid_if_no_key(
|
|||
m.assert_called_once_with(p)
|
||||
|
||||
|
||||
def test_import_job_can_be_skipped(
|
||||
artists, albums, tracks, factories, mocker, preferences):
|
||||
preferences['providers_acoustid__api_key'] = 'test'
|
||||
def test_import_job_skip_if_already_exists(
|
||||
artists, albums, tracks, factories, mocker):
|
||||
path = os.path.join(DATA_DIR, 'test.ogg')
|
||||
mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
|
||||
track_file = factories['music.TrackFile'](track__mbid=mbid)
|
||||
acoustid_payload = {
|
||||
'results': [
|
||||
{'id': 'e475bf79-c1ce-4441-bed7-1e33f226c0a2',
|
||||
'recordings': [
|
||||
{
|
||||
'duration': 268,
|
||||
'id': mbid}],
|
||||
'score': 0.860825}],
|
||||
'status': 'ok'
|
||||
}
|
||||
mocker.patch(
|
||||
'funkwhale_api.musicbrainz.api.artists.get',
|
||||
return_value=artists['get']['adhesive_wombat'])
|
||||
mocker.patch(
|
||||
'funkwhale_api.musicbrainz.api.releases.get',
|
||||
return_value=albums['get']['marsupial'])
|
||||
mocker.patch(
|
||||
'funkwhale_api.musicbrainz.api.recordings.search',
|
||||
return_value=tracks['search']['8bitadventures'])
|
||||
mocker.patch('acoustid.match', return_value=acoustid_payload)
|
||||
'funkwhale_api.providers.audiofile.tasks.import_track_data_from_path',
|
||||
return_value=track_file.track)
|
||||
|
||||
job = factories['music.FileImportJob'](audio_file__path=path)
|
||||
f = job.audio_file
|
||||
|
@ -171,30 +163,94 @@ def test_import_job_can_be_skipped(
|
|||
|
||||
|
||||
def test_import_job_can_be_errored(factories, mocker, preferences):
|
||||
preferences['providers_acoustid__api_key'] = 'test'
|
||||
path = os.path.join(DATA_DIR, 'test.ogg')
|
||||
mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
|
||||
track_file = factories['music.TrackFile'](track__mbid=mbid)
|
||||
acoustid_payload = {
|
||||
'results': [
|
||||
{'id': 'e475bf79-c1ce-4441-bed7-1e33f226c0a2',
|
||||
'recordings': [
|
||||
{
|
||||
'duration': 268,
|
||||
'id': mbid}],
|
||||
'score': 0.860825}],
|
||||
'status': 'ok'
|
||||
}
|
||||
|
||||
class MyException(Exception):
|
||||
pass
|
||||
mocker.patch('acoustid.match', side_effect=MyException())
|
||||
|
||||
mocker.patch(
|
||||
'funkwhale_api.music.tasks._do_import',
|
||||
side_effect=MyException())
|
||||
|
||||
job = factories['music.FileImportJob'](
|
||||
audio_file__path=path, track_file=None)
|
||||
|
||||
with pytest.raises(MyException):
|
||||
tasks.import_job_run(import_job_id=job.pk)
|
||||
|
||||
job.refresh_from_db()
|
||||
|
||||
assert job.track_file is None
|
||||
assert job.status == 'errored'
|
||||
|
||||
|
||||
def test__do_import_calls_update_album_cover_if_no_cover(factories, mocker):
|
||||
path = os.path.join(DATA_DIR, 'test.ogg')
|
||||
album = factories['music.Album'](cover='')
|
||||
track = factories['music.Track'](album=album)
|
||||
|
||||
mocker.patch(
|
||||
'funkwhale_api.providers.audiofile.tasks.import_track_data_from_path',
|
||||
return_value=track)
|
||||
|
||||
mocked_update = mocker.patch(
|
||||
'funkwhale_api.music.tasks.update_album_cover')
|
||||
|
||||
job = factories['music.FileImportJob'](
|
||||
audio_file__path=path, track_file=None)
|
||||
|
||||
tasks.import_job_run(import_job_id=job.pk)
|
||||
|
||||
mocked_update.assert_called_once_with(album, track.files.first())
|
||||
|
||||
|
||||
def test_update_album_cover_mbid(factories, mocker):
|
||||
album = factories['music.Album'](cover='')
|
||||
|
||||
mocked_get = mocker.patch('funkwhale_api.music.models.Album.get_image')
|
||||
tasks.update_album_cover(album=album, track_file=None)
|
||||
|
||||
mocked_get.assert_called_once_with()
|
||||
|
||||
|
||||
def test_update_album_cover_file_data(factories, mocker):
|
||||
path = os.path.join(DATA_DIR, 'test.mp3')
|
||||
album = factories['music.Album'](cover='', mbid=None)
|
||||
tf = factories['music.TrackFile'](track__album=album)
|
||||
|
||||
mocked_get = mocker.patch('funkwhale_api.music.models.Album.get_image')
|
||||
mocker.patch(
|
||||
'funkwhale_api.music.metadata.Metadata.get_picture',
|
||||
return_value={'hello': 'world'})
|
||||
tasks.update_album_cover(album=album, track_file=tf)
|
||||
md = data = tf.get_metadata()
|
||||
mocked_get.assert_called_once_with(
|
||||
data={'hello': 'world'})
|
||||
|
||||
|
||||
@pytest.mark.parametrize('ext,mimetype', [
|
||||
('jpg', 'image/jpeg'),
|
||||
('png', 'image/png'),
|
||||
])
|
||||
def test_update_album_cover_file_cover_separate_file(
|
||||
ext, mimetype, factories, mocker):
|
||||
mocker.patch('funkwhale_api.music.tasks.IMAGE_TYPES', [(ext, mimetype)])
|
||||
path = os.path.join(DATA_DIR, 'test.mp3')
|
||||
image_path = os.path.join(DATA_DIR, 'cover.{}'.format(ext))
|
||||
with open(image_path, 'rb') as f:
|
||||
image_content = f.read()
|
||||
album = factories['music.Album'](cover='', mbid=None)
|
||||
tf = factories['music.TrackFile'](
|
||||
track__album=album,
|
||||
source='file://' + image_path)
|
||||
|
||||
mocked_get = mocker.patch('funkwhale_api.music.models.Album.get_image')
|
||||
mocker.patch(
|
||||
'funkwhale_api.music.metadata.Metadata.get_picture',
|
||||
return_value=None)
|
||||
tasks.update_album_cover(album=album, track_file=tf)
|
||||
md = data = tf.get_metadata()
|
||||
mocked_get.assert_called_once_with(
|
||||
data={'mimetype': mimetype, 'content': image_content})
|
||||
|
|
Binary file not shown.
|
@ -4,16 +4,76 @@ import pytest
|
|||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from funkwhale_api.music import serializers
|
||||
from funkwhale_api.music import views
|
||||
from funkwhale_api.federation import actors
|
||||
|
||||
|
||||
@pytest.mark.parametrize('view,permissions', [
|
||||
(views.ImportBatchViewSet, ['library']),
|
||||
(views.ImportJobViewSet, ['library']),
|
||||
@pytest.mark.parametrize('view,permissions,operator', [
|
||||
(views.ImportBatchViewSet, ['library', 'upload'], 'or'),
|
||||
(views.ImportJobViewSet, ['library', 'upload'], 'or'),
|
||||
])
|
||||
def test_permissions(assert_user_permission, view, permissions):
|
||||
assert_user_permission(view, permissions)
|
||||
def test_permissions(assert_user_permission, view, permissions, operator):
|
||||
assert_user_permission(view, permissions, operator)
|
||||
|
||||
|
||||
def test_artist_list_serializer(api_request, factories, logged_in_api_client):
|
||||
track = factories['music.Track']()
|
||||
artist = track.artist
|
||||
request = api_request.get('/')
|
||||
qs = artist.__class__.objects.with_albums()
|
||||
serializer = serializers.ArtistWithAlbumsSerializer(
|
||||
qs, many=True, context={'request': request})
|
||||
expected = {
|
||||
'count': 1,
|
||||
'next': None,
|
||||
'previous': None,
|
||||
'results': serializer.data
|
||||
}
|
||||
url = reverse('api:v1:artists-list')
|
||||
response = logged_in_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data == expected
|
||||
|
||||
|
||||
def test_album_list_serializer(api_request, factories, logged_in_api_client):
|
||||
track = factories['music.Track']()
|
||||
album = track.album
|
||||
request = api_request.get('/')
|
||||
qs = album.__class__.objects.all()
|
||||
serializer = serializers.AlbumSerializer(
|
||||
qs, many=True, context={'request': request})
|
||||
expected = {
|
||||
'count': 1,
|
||||
'next': None,
|
||||
'previous': None,
|
||||
'results': serializer.data
|
||||
}
|
||||
url = reverse('api:v1:albums-list')
|
||||
response = logged_in_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data == expected
|
||||
|
||||
|
||||
def test_track_list_serializer(api_request, factories, logged_in_api_client):
|
||||
track = factories['music.Track']()
|
||||
request = api_request.get('/')
|
||||
qs = track.__class__.objects.all()
|
||||
serializer = serializers.TrackSerializer(
|
||||
qs, many=True, context={'request': request})
|
||||
expected = {
|
||||
'count': 1,
|
||||
'next': None,
|
||||
'previous': None,
|
||||
'results': serializer.data
|
||||
}
|
||||
url = reverse('api:v1:tracks-list')
|
||||
response = logged_in_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('param,expected', [
|
||||
|
@ -59,8 +119,8 @@ def test_album_view_filter_listenable(
|
|||
|
||||
|
||||
def test_can_serve_track_file_as_remote_library(
|
||||
factories, authenticated_actor, settings, api_client):
|
||||
settings.PROTECT_AUDIO_FILES = True
|
||||
factories, authenticated_actor, api_client, settings, preferences):
|
||||
preferences['common__api_authentication_required'] = True
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
follow = factories['federation.Follow'](
|
||||
approved=True,
|
||||
|
@ -77,8 +137,8 @@ def test_can_serve_track_file_as_remote_library(
|
|||
|
||||
|
||||
def test_can_serve_track_file_as_remote_library_deny_not_following(
|
||||
factories, authenticated_actor, settings, api_client):
|
||||
settings.PROTECT_AUDIO_FILES = True
|
||||
factories, authenticated_actor, settings, api_client, preferences):
|
||||
preferences['common__api_authentication_required'] = True
|
||||
track_file = factories['music.TrackFile']()
|
||||
response = api_client.get(track_file.path)
|
||||
|
||||
|
@ -92,12 +152,18 @@ def test_can_serve_track_file_as_remote_library_deny_not_following(
|
|||
('nginx', '/app/music', '/_protected/music/hello/world.mp3'),
|
||||
])
|
||||
def test_serve_file_in_place(
|
||||
proxy, serve_path, expected, factories, api_client, settings):
|
||||
proxy,
|
||||
serve_path,
|
||||
expected,
|
||||
factories,
|
||||
api_client,
|
||||
preferences,
|
||||
settings):
|
||||
headers = {
|
||||
'apache2': 'X-Sendfile',
|
||||
'nginx': 'X-Accel-Redirect',
|
||||
}
|
||||
settings.PROTECT_AUDIO_FILES = False
|
||||
preferences['common__api_authentication_required'] = False
|
||||
settings.PROTECT_FILE_PATH = '/_protected/music'
|
||||
settings.REVERSE_PROXY_TYPE = proxy
|
||||
settings.MUSIC_DIRECTORY_PATH = '/app/music'
|
||||
|
@ -119,8 +185,14 @@ def test_serve_file_in_place(
|
|||
('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
|
||||
proxy,
|
||||
serve_path,
|
||||
expected,
|
||||
factories,
|
||||
api_client,
|
||||
settings,
|
||||
preferences):
|
||||
preferences['common__api_authentication_required'] = False
|
||||
settings.PROTECT_FILE_PATH = '/_protected/music'
|
||||
settings.REVERSE_PROXY_TYPE = proxy
|
||||
settings.MUSIC_DIRECTORY_PATH = '/app/music'
|
||||
|
@ -138,12 +210,18 @@ def test_serve_file_in_place_utf8(
|
|||
('nginx', '/app/music', '/_protected/media/tracks/hello/world.mp3'),
|
||||
])
|
||||
def test_serve_file_media(
|
||||
proxy, serve_path, expected, factories, api_client, settings):
|
||||
proxy,
|
||||
serve_path,
|
||||
expected,
|
||||
factories,
|
||||
api_client,
|
||||
settings,
|
||||
preferences):
|
||||
headers = {
|
||||
'apache2': 'X-Sendfile',
|
||||
'nginx': 'X-Accel-Redirect',
|
||||
}
|
||||
settings.PROTECT_AUDIO_FILES = False
|
||||
preferences['common__api_authentication_required'] = False
|
||||
settings.MEDIA_ROOT = '/host/media'
|
||||
settings.PROTECT_FILE_PATH = '/_protected/music'
|
||||
settings.REVERSE_PROXY_TYPE = proxy
|
||||
|
@ -160,8 +238,8 @@ def test_serve_file_media(
|
|||
|
||||
|
||||
def test_can_proxy_remote_track(
|
||||
factories, settings, api_client, r_mock):
|
||||
settings.PROTECT_AUDIO_FILES = False
|
||||
factories, settings, api_client, r_mock, preferences):
|
||||
preferences['common__api_authentication_required'] = False
|
||||
track_file = factories['music.TrackFile'](federation=True)
|
||||
|
||||
r_mock.get(track_file.library_track.audio_url, body=io.BytesIO(b'test'))
|
||||
|
@ -176,8 +254,9 @@ def test_can_proxy_remote_track(
|
|||
assert library_track.audio_file.read() == b'test'
|
||||
|
||||
|
||||
def test_serve_updates_access_date(factories, settings, api_client):
|
||||
settings.PROTECT_AUDIO_FILES = False
|
||||
def test_serve_updates_access_date(
|
||||
factories, settings, api_client, preferences):
|
||||
preferences['common__api_authentication_required'] = False
|
||||
track_file = factories['music.TrackFile']()
|
||||
now = timezone.now()
|
||||
assert track_file.accessed_date is None
|
||||
|
@ -189,24 +268,6 @@ def test_serve_updates_access_date(factories, settings, api_client):
|
|||
assert track_file.accessed_date > now
|
||||
|
||||
|
||||
def test_can_create_import_from_federation_tracks(
|
||||
factories, superuser_api_client, mocker):
|
||||
lts = factories['federation.LibraryTrack'].create_batch(size=5)
|
||||
mocker.patch('funkwhale_api.music.tasks.import_job_run')
|
||||
|
||||
payload = {
|
||||
'library_tracks': [l.pk for l in lts]
|
||||
}
|
||||
url = reverse('api:v1:submit-federation')
|
||||
response = superuser_api_client.post(url, payload)
|
||||
|
||||
assert response.status_code == 201
|
||||
batch = superuser_api_client.user.imports.latest('id')
|
||||
assert batch.jobs.count() == 5
|
||||
for i, job in enumerate(batch.jobs.all()):
|
||||
assert job.library_track == lts[i]
|
||||
|
||||
|
||||
def test_can_list_import_jobs(factories, superuser_api_client):
|
||||
job = factories['music.ImportJob']()
|
||||
url = reverse('api:v1:import-jobs-list')
|
||||
|
@ -309,3 +370,27 @@ def test_import_batch_and_job_run_via_api(
|
|||
|
||||
run.assert_any_call(import_job_id=job1.pk)
|
||||
run.assert_any_call(import_job_id=job2.pk)
|
||||
|
||||
|
||||
def test_import_job_viewset_get_queryset_upload_filters_user(
|
||||
factories, logged_in_api_client):
|
||||
logged_in_api_client.user.permission_upload = True
|
||||
logged_in_api_client.user.save()
|
||||
|
||||
job = factories['music.ImportJob']()
|
||||
url = reverse('api:v1:import-jobs-list')
|
||||
response = logged_in_api_client.get(url)
|
||||
|
||||
assert response.data['count'] == 0
|
||||
|
||||
|
||||
def test_import_batch_viewset_get_queryset_upload_filters_user(
|
||||
factories, logged_in_api_client):
|
||||
logged_in_api_client.user.permission_upload = True
|
||||
logged_in_api_client.user.save()
|
||||
|
||||
job = factories['music.ImportBatch']()
|
||||
url = reverse('api:v1:import-batches-list')
|
||||
response = logged_in_api_client.get(url)
|
||||
|
||||
assert response.data['count'] == 0
|
||||
|
|
|
@ -3,7 +3,7 @@ import pytest
|
|||
|
||||
from django.urls import reverse
|
||||
|
||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||
from funkwhale_api.music.serializers import TrackSerializer
|
||||
from funkwhale_api.radios import filters
|
||||
from funkwhale_api.radios import serializers
|
||||
|
||||
|
@ -43,7 +43,7 @@ def test_can_validate_config(logged_in_client, factories):
|
|||
|
||||
expected = {
|
||||
'count': candidates.count(),
|
||||
'sample': TrackSerializerNested(candidates, many=True).data
|
||||
'sample': TrackSerializer(candidates, many=True).data
|
||||
}
|
||||
assert payload['filters'][0]['candidates'] == expected
|
||||
assert payload['filters'][0]['errors'] == []
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import binascii
|
||||
import pytest
|
||||
|
||||
from rest_framework import exceptions
|
||||
|
||||
from funkwhale_api.subsonic import authentication
|
||||
|
||||
|
@ -54,3 +57,19 @@ def test_auth_with_password_cleartext(api_request, factories):
|
|||
u, _ = authenticator.authenticate(request)
|
||||
|
||||
assert user == u
|
||||
|
||||
|
||||
def test_auth_with_inactive_users(api_request, factories):
|
||||
salt = 'salt'
|
||||
user = factories['users.User'](is_active=False)
|
||||
user.subsonic_api_token = 'password'
|
||||
user.save()
|
||||
token = authentication.get_token(salt, 'password')
|
||||
request = api_request.get('/', {
|
||||
'u': user.username,
|
||||
'p': 'password',
|
||||
})
|
||||
|
||||
authenticator = authentication.SubsonicAuthentication()
|
||||
with pytest.raises(exceptions.AuthenticationFailed):
|
||||
authenticator.authenticate(request)
|
||||
|
|
|
@ -60,6 +60,7 @@ def test_get_artist_serializer(factories):
|
|||
'album': [
|
||||
{
|
||||
'id': album.pk,
|
||||
'coverArt': 'al-{}'.format(album.id),
|
||||
'artistId': artist.pk,
|
||||
'name': album.title,
|
||||
'artist': artist.name,
|
||||
|
@ -88,11 +89,13 @@ def test_get_album_serializer(factories):
|
|||
'songCount': 1,
|
||||
'created': album.creation_date,
|
||||
'year': album.release_date.year,
|
||||
'coverArt': 'al-{}'.format(album.id),
|
||||
'song': [
|
||||
{
|
||||
'id': track.pk,
|
||||
'isDir': 'false',
|
||||
'title': track.title,
|
||||
'coverArt': 'al-{}'.format(album.id),
|
||||
'album': album.title,
|
||||
'artist': artist.name,
|
||||
'track': track.position,
|
||||
|
@ -211,3 +214,22 @@ def test_directory_serializer_artist(factories):
|
|||
}
|
||||
data = serializers.get_music_directory_data(artist)
|
||||
assert data == expected
|
||||
|
||||
|
||||
def test_scrobble_serializer(factories):
|
||||
tf = factories['music.TrackFile']()
|
||||
track = tf.track
|
||||
user = factories['users.User']()
|
||||
payload = {
|
||||
'id': track.pk,
|
||||
'submission': True,
|
||||
}
|
||||
serializer = serializers.ScrobbleSerializer(
|
||||
data=payload, context={'user': user})
|
||||
|
||||
assert serializer.is_valid(raise_exception=True)
|
||||
|
||||
listening = serializer.save()
|
||||
|
||||
assert listening.user == user
|
||||
assert listening.track == track
|
||||
|
|
|
@ -391,3 +391,30 @@ def test_get_indexes(f, db, logged_in_api_client, factories):
|
|||
|
||||
assert response.status_code == 200
|
||||
assert response.data == expected
|
||||
|
||||
|
||||
def test_get_cover_art_album(factories, logged_in_api_client):
|
||||
url = reverse('api:subsonic-get-cover-art')
|
||||
assert url.endswith('getCoverArt') is True
|
||||
album = factories['music.Album']()
|
||||
response = logged_in_api_client.get(url, {'id': 'al-{}'.format(album.pk)})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response['Content-Type'] == ''
|
||||
assert response['X-Accel-Redirect'] == music_views.get_file_path(
|
||||
album.cover
|
||||
).decode('utf-8')
|
||||
|
||||
|
||||
def test_scrobble(factories, logged_in_api_client):
|
||||
tf = factories['music.TrackFile']()
|
||||
track = tf.track
|
||||
url = reverse('api:subsonic-scrobble')
|
||||
assert url.endswith('scrobble') is True
|
||||
response = logged_in_api_client.get(
|
||||
url, {'id': track.pk, 'submission': True})
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
l = logged_in_api_client.user.listenings.latest('id')
|
||||
assert l.track == track
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import pytest
|
||||
import acoustid
|
||||
import datetime
|
||||
import os
|
||||
import uuid
|
||||
|
@ -17,8 +16,6 @@ DATA_DIR = os.path.join(
|
|||
|
||||
|
||||
def test_can_create_track_from_file_metadata(db, mocker):
|
||||
mocker.patch(
|
||||
'acoustid.match', side_effect=acoustid.WebServiceError('test'))
|
||||
metadata = {
|
||||
'artist': ['Test artist'],
|
||||
'album': ['Test album'],
|
||||
|
@ -94,24 +91,6 @@ def test_import_files_creates_a_batch_and_job(factories, mocker):
|
|||
assert job.audio_file.read() == f.read()
|
||||
|
||||
assert job.source == 'file://' + path
|
||||
m.assert_called_once_with(
|
||||
import_job_id=job.pk,
|
||||
use_acoustid=True)
|
||||
|
||||
|
||||
def test_import_files_skip_acoustid(factories, mocker):
|
||||
m = mocker.patch('funkwhale_api.music.tasks.import_job_run')
|
||||
user = factories['users.User'](username='me')
|
||||
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
|
||||
call_command(
|
||||
'import_files',
|
||||
path,
|
||||
username='me',
|
||||
async=False,
|
||||
no_acoustid=True,
|
||||
interactive=False)
|
||||
batch = user.imports.latest('id')
|
||||
job = batch.jobs.first()
|
||||
m.assert_called_once_with(
|
||||
import_job_id=job.pk,
|
||||
use_acoustid=False)
|
||||
|
@ -128,7 +107,6 @@ def test_import_files_skip_if_path_already_imported(factories, mocker):
|
|||
path,
|
||||
username='me',
|
||||
async=False,
|
||||
no_acoustid=True,
|
||||
interactive=False)
|
||||
assert user.imports.count() == 0
|
||||
|
||||
|
@ -142,7 +120,6 @@ def test_import_files_works_with_utf8_file_name(factories, mocker):
|
|||
path,
|
||||
username='me',
|
||||
async=False,
|
||||
no_acoustid=True,
|
||||
interactive=False)
|
||||
batch = user.imports.latest('id')
|
||||
job = batch.jobs.first()
|
||||
|
@ -162,7 +139,6 @@ def test_import_files_in_place(factories, mocker, settings):
|
|||
username='me',
|
||||
async=False,
|
||||
in_place=True,
|
||||
no_acoustid=True,
|
||||
interactive=False)
|
||||
batch = user.imports.latest('id')
|
||||
job = batch.jobs.first()
|
||||
|
|
|
@ -41,12 +41,34 @@ def test_get_permissions_regular(factories):
|
|||
assert perms[p] is False
|
||||
|
||||
|
||||
def test_get_permissions_default(factories, preferences):
|
||||
preferences['users__default_permissions'] = ['upload', 'federation']
|
||||
user = factories['users.User']()
|
||||
|
||||
perms = user.get_permissions()
|
||||
assert perms['upload'] is True
|
||||
assert perms['federation'] is True
|
||||
assert perms['library'] is False
|
||||
assert perms['settings'] 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):
|
||||
def test_has_permissions_and(args, perms, expected, factories):
|
||||
user = factories['users.User'](**args)
|
||||
assert user.has_permissions(*perms) is expected
|
||||
assert user.has_permissions(*perms, operator='and') is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('args,perms,expected', [
|
||||
({'is_superuser': True}, ['federation', 'library'], True),
|
||||
({'is_superuser': False}, ['federation'], False),
|
||||
({'permission_library': True}, ['library', 'federation'], True),
|
||||
({'permission_library': True}, ['federation'], False),
|
||||
])
|
||||
def test_has_permissions_or(args, perms, expected, factories):
|
||||
user = factories['users.User'](**args)
|
||||
assert user.has_permissions(*perms, operator='or') is expected
|
||||
|
|
|
@ -39,7 +39,7 @@ def test_has_user_permission_logged_in_single(value, factories, api_request):
|
|||
(False, False, False),
|
||||
(True, True, True),
|
||||
])
|
||||
def test_has_user_permission_logged_in_single(
|
||||
def test_has_user_permission_logged_in_multiple_and(
|
||||
federation, library, expected, factories, api_request):
|
||||
user = factories['users.User'](
|
||||
permission_federation=federation,
|
||||
|
@ -48,9 +48,35 @@ def test_has_user_permission_logged_in_single(
|
|||
|
||||
class View(APIView):
|
||||
required_permissions = ['federation', 'library']
|
||||
permission_operator = 'and'
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize('federation,library,expected', [
|
||||
(True, False, True),
|
||||
(False, True, True),
|
||||
(False, False, False),
|
||||
(True, True, True),
|
||||
])
|
||||
def test_has_user_permission_logged_in_multiple_or(
|
||||
federation, library, expected, factories, api_request):
|
||||
user = factories['users.User'](
|
||||
permission_federation=federation,
|
||||
permission_library=library,
|
||||
)
|
||||
|
||||
class View(APIView):
|
||||
required_permissions = ['federation', 'library']
|
||||
permission_operator = 'or'
|
||||
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', operator='or') == expected
|
||||
|
|
|
@ -7,7 +7,7 @@ from django.urls import reverse
|
|||
from funkwhale_api.users.models import User
|
||||
|
||||
|
||||
def test_can_create_user_via_api(preferences, client, db):
|
||||
def test_can_create_user_via_api(preferences, api_client, db):
|
||||
url = reverse('rest_register')
|
||||
data = {
|
||||
'username': 'test1',
|
||||
|
@ -16,14 +16,14 @@ def test_can_create_user_via_api(preferences, client, db):
|
|||
'password2': 'testtest',
|
||||
}
|
||||
preferences['users__registration_enabled'] = True
|
||||
response = client.post(url, data)
|
||||
response = api_client.post(url, data)
|
||||
assert response.status_code == 201
|
||||
|
||||
u = User.objects.get(email='test1@test.com')
|
||||
assert u.username == 'test1'
|
||||
|
||||
|
||||
def test_can_restrict_usernames(settings, preferences, db, client):
|
||||
def test_can_restrict_usernames(settings, preferences, db, api_client):
|
||||
url = reverse('rest_register')
|
||||
preferences['users__registration_enabled'] = True
|
||||
settings.USERNAME_BLACKLIST = ['funkwhale']
|
||||
|
@ -34,13 +34,13 @@ def test_can_restrict_usernames(settings, preferences, db, client):
|
|||
'password2': 'testtest',
|
||||
}
|
||||
|
||||
response = client.post(url, data)
|
||||
response = api_client.post(url, data)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'username' in response.data
|
||||
|
||||
|
||||
def test_can_disable_registration_view(preferences, client, db):
|
||||
def test_can_disable_registration_view(preferences, api_client, db):
|
||||
url = reverse('rest_register')
|
||||
data = {
|
||||
'username': 'test1',
|
||||
|
@ -49,7 +49,7 @@ def test_can_disable_registration_view(preferences, client, db):
|
|||
'password2': 'testtest',
|
||||
}
|
||||
preferences['users__registration_enabled'] = False
|
||||
response = client.post(url, data)
|
||||
response = api_client.post(url, data)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
|
@ -73,7 +73,7 @@ def test_can_fetch_data_from_api(api_client, factories):
|
|||
assert response.data['permissions'] == user.get_permissions()
|
||||
|
||||
|
||||
def test_can_get_token_via_api(client, factories):
|
||||
def test_can_get_token_via_api(api_client, factories):
|
||||
user = factories['users.User']()
|
||||
url = reverse('api:v1:token')
|
||||
payload = {
|
||||
|
@ -81,12 +81,24 @@ def test_can_get_token_via_api(client, factories):
|
|||
'password': 'test'
|
||||
}
|
||||
|
||||
response = client.post(url, payload)
|
||||
response = api_client.post(url, payload)
|
||||
assert response.status_code == 200
|
||||
assert '"token":' in response.content.decode('utf-8')
|
||||
assert 'token' in response.data
|
||||
|
||||
|
||||
def test_can_refresh_token_via_api(client, factories):
|
||||
def test_can_get_token_via_api_inactive(api_client, factories):
|
||||
user = factories['users.User'](is_active=False)
|
||||
url = reverse('api:v1:token')
|
||||
payload = {
|
||||
'username': user.username,
|
||||
'password': 'test'
|
||||
}
|
||||
|
||||
response = api_client.post(url, payload)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_can_refresh_token_via_api(api_client, factories, mocker):
|
||||
# first, we get a token
|
||||
user = factories['users.User']()
|
||||
url = reverse('api:v1:token')
|
||||
|
@ -95,21 +107,19 @@ def test_can_refresh_token_via_api(client, factories):
|
|||
'password': 'test'
|
||||
}
|
||||
|
||||
response = client.post(url, payload)
|
||||
response = api_client.post(url, payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
token = json.loads(response.content.decode('utf-8'))['token']
|
||||
token = response.data['token']
|
||||
url = reverse('api:v1:token_refresh')
|
||||
response = client.post(url,{'token': token})
|
||||
response = api_client.post(url, {'token': token})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert '"token":' in response.content.decode('utf-8')
|
||||
# a different token should be returned
|
||||
assert token in response.content.decode('utf-8')
|
||||
assert 'token' in response.data
|
||||
|
||||
|
||||
def test_changing_password_updates_secret_key(logged_in_client):
|
||||
user = logged_in_client.user
|
||||
def test_changing_password_updates_secret_key(logged_in_api_client):
|
||||
user = logged_in_api_client.user
|
||||
password = user.password
|
||||
secret_key = user.secret_key
|
||||
payload = {
|
||||
|
@ -119,7 +129,7 @@ def test_changing_password_updates_secret_key(logged_in_client):
|
|||
}
|
||||
url = reverse('change_password')
|
||||
|
||||
response = logged_in_client.post(url, payload)
|
||||
response = logged_in_api_client.post(url, payload)
|
||||
|
||||
user.refresh_from_db()
|
||||
|
||||
|
|
|
@ -23,8 +23,6 @@ echo "DJANGO_SECRET_KEY=demo" >> .env
|
|||
echo "DJANGO_ALLOWED_HOSTS=demo.funkwhale.audio" >> .env
|
||||
echo "FUNKWHALE_VERSION=$version" >> .env
|
||||
echo "FUNKWHALE_API_PORT=5001" >> .env
|
||||
echo "FEDERATION_MUSIC_NEEDS_APPROVAL=False" >>.env
|
||||
echo "PROTECT_AUDIO_FILES=False" >> .env
|
||||
/usr/local/bin/docker-compose pull
|
||||
/usr/local/bin/docker-compose up -d postgres redis
|
||||
sleep 5
|
||||
|
|
|
@ -84,6 +84,12 @@ Define MUSIC_DIRECTORY_PATH /srv/funkwhale/data/music
|
|||
ProxyPassReverse ${funkwhale-api}/federation
|
||||
</Location>
|
||||
|
||||
# You can comment this if you don't plan to use the Subsonic API
|
||||
<Location "/rest">
|
||||
ProxyPass ${funkwhale-api}/api/subsonic/rest
|
||||
ProxyPassReverse ${funkwhale-api}/api/subsonic/rest
|
||||
</Location>
|
||||
|
||||
<Location "/.well-known/">
|
||||
ProxyPass ${funkwhale-api}/.well-known/
|
||||
ProxyPassReverse ${funkwhale-api}/.well-known/
|
||||
|
|
|
@ -67,6 +67,12 @@ server {
|
|||
proxy_pass http://funkwhale-api/federation/;
|
||||
}
|
||||
|
||||
# You can comment this if you do not plan to use the Subsonic API
|
||||
location /rest/ {
|
||||
include /etc/nginx/funkwhale_proxy.conf;
|
||||
proxy_pass http://funkwhale-api/api/subsonic/rest/;
|
||||
}
|
||||
|
||||
location /.well-known/ {
|
||||
include /etc/nginx/funkwhale_proxy.conf;
|
||||
proxy_pass http://funkwhale-api/.well-known/;
|
||||
|
|
2
dev.yml
2
dev.yml
|
@ -130,7 +130,7 @@ services:
|
|||
ports:
|
||||
- '8002:8080'
|
||||
volumes:
|
||||
- "./api/docs/swagger.yml:/usr/share/nginx/html/swagger.yml"
|
||||
- "./docs/swagger.yml:/usr/share/nginx/html/swagger.yml"
|
||||
|
||||
networks:
|
||||
internal:
|
||||
|
|
|
@ -36,7 +36,7 @@ http {
|
|||
server {
|
||||
listen 6001;
|
||||
charset utf-8;
|
||||
client_max_body_size 20M;
|
||||
client_max_body_size 30M;
|
||||
include /etc/nginx/funkwhale_proxy.conf;
|
||||
location /_protected/media {
|
||||
internal;
|
||||
|
|
|
@ -12,6 +12,7 @@ cp /etc/nginx/funkwhale_proxy.conf{.template,}
|
|||
sed -i "s/X-Forwarded-Host \$host:\$server_port/X-Forwarded-Host ${FUNKWHALE_HOSTNAME}:${FORWARDED_PORT}/" /etc/nginx/funkwhale_proxy.conf
|
||||
sed -i "s/proxy_set_header Host \$host/proxy_set_header Host ${FUNKWHALE_HOSTNAME}/" /etc/nginx/funkwhale_proxy.conf
|
||||
sed -i "s/proxy_set_header X-Forwarded-Port \$server_port/proxy_set_header X-Forwarded-Port ${FORWARDED_PORT}/" /etc/nginx/funkwhale_proxy.conf
|
||||
sed -i "s/proxy_set_header X-Forwarded-Proto \$scheme/proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO}/" /etc/nginx/funkwhale_proxy.conf
|
||||
|
||||
cat /etc/nginx/funkwhale_proxy.conf
|
||||
nginx -g "daemon off;"
|
||||
|
|
|
@ -6,7 +6,8 @@ From music directory on the server
|
|||
|
||||
You can import music files in funkwhale assuming they are located on the server
|
||||
and readable by the funkwhale application. Your music files should contain at
|
||||
least an ``artist``, ``album`` and ``title`` tags.
|
||||
least an ``artist``, ``album`` and ``title`` tags, but we recommend you tag
|
||||
it extensively using a proper tool, such as Beets or Musicbrainz Picard.
|
||||
|
||||
You can import those tracks as follows, assuming they are located in
|
||||
``/srv/funkwhale/data/music``:
|
||||
|
@ -32,11 +33,6 @@ get details::
|
|||
For the best results, we recommand tagging your music collection through
|
||||
`Picard <http://picard.musicbrainz.org/>`_ in order to have the best quality metadata.
|
||||
|
||||
.. note::
|
||||
|
||||
Autotagging using acoustid is experimental now and can yield unexpected
|
||||
result. You can disable acoustid by passing the --no-acoustid flag.
|
||||
|
||||
.. note::
|
||||
|
||||
This command is idempotent, meaning you can run it multiple times on the same
|
||||
|
@ -44,7 +40,7 @@ get details::
|
|||
|
||||
.. note::
|
||||
|
||||
At the moment, only OGG/Vorbis and MP3 files with ID3 tags are supported
|
||||
At the moment, only Flac, OGG/Vorbis and MP3 files with ID3 tags are supported
|
||||
|
||||
|
||||
.. _in-place-import:
|
||||
|
@ -80,6 +76,15 @@ configuration options to ensure the webserver can serve them properly:
|
|||
|
||||
Thus, be especially careful when you manipulate the source files.
|
||||
|
||||
Album covers
|
||||
^^^^^^^^^^^^
|
||||
|
||||
Whenever possible, Funkwhale will import album cover, with the following precedence:
|
||||
|
||||
1. It will use the cover embedded in the audio files themeselves, if any (Flac/MP3 only)
|
||||
2. It will use a cover.jpg or a cover.png file from the imported track directory, if any
|
||||
3. It will fectch cover art from musicbrainz, assuming the file is tagged correctly
|
||||
|
||||
Getting demo tracks
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
|
|
|
@ -14,11 +14,11 @@ Funkwhale is a self-hosted, modern free and open-source music server, heavily in
|
|||
users/index
|
||||
features
|
||||
installation/index
|
||||
upgrading
|
||||
configuration
|
||||
importing-music
|
||||
federation
|
||||
api
|
||||
upgrading
|
||||
third-party
|
||||
contributing
|
||||
changelog
|
||||
|
|
|
@ -78,7 +78,7 @@ paths:
|
|||
results:
|
||||
type: "array"
|
||||
items:
|
||||
$ref: "#/definitions/ArtistNested"
|
||||
$ref: "#/definitions/ArtistWithAlbums"
|
||||
|
||||
properties:
|
||||
resultsCount:
|
||||
|
@ -106,7 +106,7 @@ definitions:
|
|||
creation_date:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
ArtistNested:
|
||||
ArtistWithAlbums:
|
||||
type: "object"
|
||||
allOf:
|
||||
- $ref: "#/definitions/Artist"
|
||||
|
@ -115,7 +115,7 @@ definitions:
|
|||
albums:
|
||||
type: "array"
|
||||
items:
|
||||
$ref: "#/definitions/AlbumNested"
|
||||
$ref: "#/definitions/ArtistAlbum"
|
||||
|
||||
Album:
|
||||
type: "object"
|
||||
|
@ -143,16 +143,16 @@ definitions:
|
|||
format: "date"
|
||||
example: "2001-01-01"
|
||||
|
||||
AlbumNested:
|
||||
ArtistAlbum:
|
||||
type: "object"
|
||||
allOf:
|
||||
- $ref: "#/definitions/Album"
|
||||
- type: "object"
|
||||
properties:
|
||||
tracks:
|
||||
type: "array"
|
||||
items:
|
||||
$ref: "#/definitions/Track"
|
||||
tracks_count:
|
||||
type: "integer"
|
||||
format: "int64"
|
||||
example: 16
|
||||
|
||||
Track:
|
||||
type: "object"
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
"raven-js": "^3.22.3",
|
||||
"semantic-ui-css": "^2.2.10",
|
||||
"showdown": "^1.8.6",
|
||||
"vue": "^2.3.3",
|
||||
"vue": "^2.5.16",
|
||||
"vue-lazyload": "^1.1.4",
|
||||
"vue-masonry": "^0.10.16",
|
||||
"vue-router": "^2.3.1",
|
||||
|
|
|
@ -68,6 +68,18 @@
|
|||
:title="$t('Pending import requests')">
|
||||
{{ notifications.importRequests }}</div>
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
v-if="$store.state.auth.availablePermissions['library']"
|
||||
:to="{name: 'manage.library.files'}">
|
||||
<i class="book icon"></i>{{ $t('Library') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
v-else-if="$store.state.auth.availablePermissions['upload']"
|
||||
to="/library/import/launch">
|
||||
<i class="download icon"></i>{{ $t('Import music') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
v-if="$store.state.auth.availablePermissions['federation']"
|
||||
|
@ -193,7 +205,8 @@ export default {
|
|||
showAdmin () {
|
||||
let adminPermissions = [
|
||||
this.$store.state.auth.availablePermissions['federation'],
|
||||
this.$store.state.auth.availablePermissions['library']
|
||||
this.$store.state.auth.availablePermissions['library'],
|
||||
this.$store.state.auth.availablePermissions['upload']
|
||||
]
|
||||
return adminPermissions.filter(e => {
|
||||
return e
|
||||
|
|
|
@ -50,6 +50,13 @@
|
|||
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
|
||||
<p v-if="setting.help_text">{{ setting.help_text }}</p>
|
||||
</div>
|
||||
<select
|
||||
v-else-if="setting.field.class === 'MultipleChoiceField'"
|
||||
v-model="values[setting.identifier]"
|
||||
multiple
|
||||
class="ui search selection dropdown">
|
||||
<option v-for="v in setting.additional_data.choices" :value="v[0]">{{ v[1] }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
|
|
|
@ -21,7 +21,6 @@
|
|||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import logger from '@/logging'
|
||||
import jQuery from 'jquery'
|
||||
|
||||
export default {
|
||||
|
@ -30,18 +29,15 @@ export default {
|
|||
tracks: {type: Array, required: false},
|
||||
track: {type: Object, required: false},
|
||||
playlist: {type: Object, required: false},
|
||||
discrete: {type: Boolean, default: false}
|
||||
discrete: {type: Boolean, default: false},
|
||||
artist: {type: Number, required: false},
|
||||
album: {type: Number, required: false}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: false
|
||||
}
|
||||
},
|
||||
created () {
|
||||
if (!this.playlist && !this.track && !this.tracks) {
|
||||
logger.default.error('You have to provide either a track playlist or tracks property')
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
jQuery(this.$el).find('.ui.dropdown').dropdown()
|
||||
},
|
||||
|
@ -62,6 +58,10 @@ export default {
|
|||
return this.tracks.length > 0
|
||||
} else if (this.playlist) {
|
||||
return true
|
||||
} else if (this.artist) {
|
||||
return true
|
||||
} else if (this.album) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@ -81,6 +81,20 @@ export default {
|
|||
return plt.track
|
||||
}))
|
||||
})
|
||||
} else if (self.artist) {
|
||||
let params = {
|
||||
params: {'artist': self.artist, 'ordering': 'album__release_date,position'}
|
||||
}
|
||||
axios.get('tracks', params).then((response) => {
|
||||
resolve(response.data.results)
|
||||
})
|
||||
} else if (self.album) {
|
||||
let params = {
|
||||
params: {'album': self.album, 'ordering': 'position'}
|
||||
}
|
||||
axios.get('tracks', params).then((response) => {
|
||||
resolve(response.data.results)
|
||||
})
|
||||
}
|
||||
})
|
||||
return getTracks.then((tracks) => {
|
||||
|
|
|
@ -18,10 +18,10 @@
|
|||
<router-link class="discrete link":to="{name: 'library.albums.detail', params: {id: album.id }}">
|
||||
<strong>{{ album.title }}</strong>
|
||||
</router-link><br />
|
||||
{{ album.tracks.length }} tracks
|
||||
{{ album.tracks_count }} tracks
|
||||
</td>
|
||||
<td>
|
||||
<play-button class="right floated basic icon" :discrete="true" :tracks="album.tracks"></play-button>
|
||||
<play-button class="right floated basic icon" :discrete="true" :album="album.id"></play-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
@ -45,7 +45,7 @@
|
|||
{{ artist.albums.length }}
|
||||
</i18next>
|
||||
</span>
|
||||
<play-button class="mini basic orange right floated" :tracks="allTracks">
|
||||
<play-button class="mini basic orange right floated" :artist="artist.id">
|
||||
<i18next path="Play all"/>
|
||||
</play-button>
|
||||
</div>
|
||||
|
@ -74,15 +74,6 @@ export default {
|
|||
return this.artist.albums
|
||||
}
|
||||
return this.artist.albums.slice(0, this.initialAlbums)
|
||||
},
|
||||
allTracks () {
|
||||
let tracks = []
|
||||
this.artist.albums.forEach(album => {
|
||||
album.tracks.forEach(track => {
|
||||
tracks.push(track)
|
||||
})
|
||||
})
|
||||
return tracks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,215 @@
|
|||
<template>
|
||||
<table class="ui compact very basic single line unstackable table">
|
||||
<thead>
|
||||
<tr v-if="actions.length > 0">
|
||||
<th colspan="1000">
|
||||
<div class="ui small form">
|
||||
<div class="ui inline fields">
|
||||
<div class="field">
|
||||
<label>{{ $t('Actions') }}</label>
|
||||
<select class="ui dropdown" v-model="currentActionName">
|
||||
<option v-for="action in actions" :value="action.name">
|
||||
{{ action.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div
|
||||
v-if="!selectAll"
|
||||
@click="launchAction"
|
||||
:disabled="checked.length === 0"
|
||||
:class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']">
|
||||
{{ $t('Go') }}</div>
|
||||
<dangerous-button
|
||||
v-else-if="!currentAction.isDangerous" :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
|
||||
confirm-color="green"
|
||||
color=""
|
||||
@confirm="launchAction">
|
||||
{{ $t('Go') }}
|
||||
<p slot="modal-header">{{ $t('Do you want to launch action "{% action %}" on {% total %} elements?', {action: currentActionName, total: objectsData.count}) }}
|
||||
<p slot="modal-content">
|
||||
{{ $t('This may affect a lot of elements, please double check this is really what you want.')}}
|
||||
</p>
|
||||
<p slot="modal-confirm">{{ $t('Launch') }}</p>
|
||||
</dangerous-button>
|
||||
</div>
|
||||
<div class="count field">
|
||||
<span v-if="selectAll">{{ $t('{% count %} on {% total %} selected', {count: objectsData.count, total: objectsData.count}) }}</span>
|
||||
<span v-else>{{ $t('{% count %} on {% total %} selected', {count: checked.length, total: objectsData.count}) }}</span>
|
||||
<template v-if="!currentAction.isDangerous && checkable.length === checked.length">
|
||||
<a @click="selectAll = true" v-if="!selectAll">
|
||||
{{ $t('Select all {% total %} elements', {total: objectsData.count}) }}
|
||||
</a>
|
||||
<a @click="selectAll = false" v-else>
|
||||
{{ $t('Select only current page') }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="actionErrors.length > 0" class="ui negative message">
|
||||
<div class="header">{{ $t('Error while applying action') }}</div>
|
||||
<ul class="list">
|
||||
<li v-for="error in actionErrors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="actionResult" class="ui positive message">
|
||||
<p>{{ $t('Action {% action %} was launched successfully on {% count %} objects.', {action: actionResult.action, count: actionResult.updated}) }}</p>
|
||||
<slot name="action-success-footer" :result="actionResult">
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
<div class="ui checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
@change="toggleCheckAll"
|
||||
:disabled="checkable.length === 0"
|
||||
:checked="checkable.length > 0 && checked.length === checkable.length"><label> </label>
|
||||
</div>
|
||||
</th>
|
||||
<slot name="header-cells"></slot>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-if="objectsData.count > 0">
|
||||
<tr v-for="(obj, index) in objectsData.results">
|
||||
<td class="collapsing">
|
||||
<input
|
||||
type="checkbox"
|
||||
:disabled="checkable.indexOf(obj.id) === -1"
|
||||
@click="toggleCheck($event, obj.id, index)"
|
||||
:checked="checked.indexOf(obj.id) > -1"><label> </label>
|
||||
</div>
|
||||
</td>
|
||||
<slot name="row-cells" :obj="obj"></slot>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
actionUrl: {type: String, required: true},
|
||||
objectsData: {type: Object, required: true},
|
||||
actions: {type: Array, required: true, default: () => { return [] }},
|
||||
filters: {type: Object, required: false, default: () => { return {} }}
|
||||
},
|
||||
components: {},
|
||||
data () {
|
||||
let d = {
|
||||
checked: [],
|
||||
actionLoading: false,
|
||||
actionResult: null,
|
||||
actionErrors: [],
|
||||
currentActionName: null,
|
||||
selectAll: false,
|
||||
lastCheckedIndex: -1
|
||||
}
|
||||
if (this.actions.length > 0) {
|
||||
d.currentActionName = this.actions[0].name
|
||||
}
|
||||
return d
|
||||
},
|
||||
methods: {
|
||||
toggleCheckAll () {
|
||||
this.lastCheckedIndex = -1
|
||||
if (this.checked.length === this.checkable.length) {
|
||||
// we uncheck
|
||||
this.checked = []
|
||||
} else {
|
||||
this.checked = this.checkable.map(i => { return i })
|
||||
}
|
||||
},
|
||||
toggleCheck (event, id, index) {
|
||||
let self = this
|
||||
let affectedIds = [id]
|
||||
let newValue = null
|
||||
if (this.checked.indexOf(id) > -1) {
|
||||
// we uncheck
|
||||
this.selectAll = false
|
||||
newValue = false
|
||||
} else {
|
||||
newValue = true
|
||||
}
|
||||
if (event.shiftKey && this.lastCheckedIndex > -1) {
|
||||
// we also add inbetween ids to the list of affected ids
|
||||
let idxs = [index, this.lastCheckedIndex]
|
||||
idxs.sort((a, b) => a - b)
|
||||
let objs = this.objectsData.results.slice(idxs[0], idxs[1] + 1)
|
||||
affectedIds = affectedIds.concat(objs.map((o) => { return o.id }))
|
||||
}
|
||||
affectedIds.forEach((i) => {
|
||||
let checked = self.checked.indexOf(i) > -1
|
||||
if (newValue && !checked && self.checkable.indexOf(i) > -1) {
|
||||
return self.checked.push(i)
|
||||
}
|
||||
if (!newValue && checked) {
|
||||
self.checked.splice(self.checked.indexOf(i), 1)
|
||||
}
|
||||
})
|
||||
this.lastCheckedIndex = index
|
||||
},
|
||||
launchAction () {
|
||||
let self = this
|
||||
self.actionLoading = true
|
||||
self.result = null
|
||||
let payload = {
|
||||
action: this.currentActionName,
|
||||
filters: this.filters
|
||||
}
|
||||
if (this.selectAll) {
|
||||
payload.objects = 'all'
|
||||
} else {
|
||||
payload.objects = this.checked
|
||||
}
|
||||
axios.post(this.actionUrl, payload).then((response) => {
|
||||
self.actionResult = response.data
|
||||
self.actionLoading = false
|
||||
self.$emit('action-launched', response.data)
|
||||
}, error => {
|
||||
self.actionLoading = false
|
||||
self.actionErrors = error.backendErrors
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentAction () {
|
||||
let self = this
|
||||
return this.actions.filter((a) => {
|
||||
return a.name === self.currentActionName
|
||||
})[0]
|
||||
},
|
||||
checkable () {
|
||||
let objs = this.objectsData.results
|
||||
let filter = this.currentAction.filterCheckable
|
||||
if (filter) {
|
||||
objs = objs.filter((o) => {
|
||||
return filter(o)
|
||||
})
|
||||
}
|
||||
return objs.map((o) => { return o.id })
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
objectsData: {
|
||||
handler () {
|
||||
this.checked = []
|
||||
this.selectAll = false
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.count.field {
|
||||
font-weight: normal;
|
||||
}
|
||||
.ui.form .inline.fields {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
|
@ -13,7 +13,7 @@
|
|||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui cancel button"><i18next path="Cancel"/></div>
|
||||
<div :class="['ui', 'confirm', color, 'button']" @click="confirm">
|
||||
<div :class="['ui', 'confirm', confirmButtonColor, 'button']" @click="confirm">
|
||||
<slot name="modal-confirm"><i18next path="Confirm"/></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -28,7 +28,8 @@ export default {
|
|||
props: {
|
||||
action: {type: Function, required: false},
|
||||
disabled: {type: Boolean, default: false},
|
||||
color: {type: String, default: 'red'}
|
||||
color: {type: String, default: 'red'},
|
||||
confirmColor: {type: String, default: null, required: false}
|
||||
},
|
||||
components: {
|
||||
Modal
|
||||
|
@ -38,6 +39,14 @@ export default {
|
|||
showModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
confirmButtonColor () {
|
||||
if (this.confirmColor) {
|
||||
return this.confirmColor
|
||||
}
|
||||
return this.color
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
confirm () {
|
||||
this.showModal = false
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
<div class="field">
|
||||
<i18next tag="label" path="Ordering direction"/>
|
||||
<select class="ui dropdown" v-model="orderingDirection">
|
||||
<option value=""><i18next path="Ascending"/></option>
|
||||
<option value="+"><i18next path="Ascending"/></option>
|
||||
<option value="-"><i18next path="Descending"/></option>
|
||||
</select>
|
||||
</div>
|
||||
|
@ -74,7 +74,7 @@ export default {
|
|||
Pagination
|
||||
},
|
||||
data () {
|
||||
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || 'artist__name')
|
||||
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
|
||||
return {
|
||||
results: null,
|
||||
isLoading: false,
|
||||
|
@ -82,9 +82,10 @@ export default {
|
|||
previousLink: null,
|
||||
page: parseInt(this.defaultPage),
|
||||
paginateBy: parseInt(this.defaultPaginateBy || 25),
|
||||
orderingDirection: defaultOrdering.direction,
|
||||
orderingDirection: defaultOrdering.direction || '+',
|
||||
ordering: defaultOrdering.field,
|
||||
orderingOptions: [
|
||||
['creation_date', 'Creation date'],
|
||||
['title', 'Track name'],
|
||||
['album__title', 'Album name'],
|
||||
['artist__name', 'Artist name']
|
||||
|
@ -135,19 +136,15 @@ export default {
|
|||
watch: {
|
||||
page: function () {
|
||||
this.updateQueryString()
|
||||
this.fetchFavorites(FAVORITES_URL)
|
||||
},
|
||||
paginateBy: function () {
|
||||
this.updateQueryString()
|
||||
this.fetchFavorites(FAVORITES_URL)
|
||||
},
|
||||
orderingDirection: function () {
|
||||
this.updateQueryString()
|
||||
this.fetchFavorites(FAVORITES_URL)
|
||||
},
|
||||
ordering: function () {
|
||||
this.updateQueryString()
|
||||
this.fetchFavorites(FAVORITES_URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,95 +10,77 @@
|
|||
<label>{{ $t('Import status') }}</label>
|
||||
<select class="ui dropdown" v-model="importedFilter">
|
||||
<option :value="null">{{ $t('Any') }}</option>
|
||||
<option :value="true">{{ $t('Imported') }}</option>
|
||||
<option :value="false">{{ $t('Not imported') }}</option>
|
||||
<option :value="'imported'">{{ $t('Imported') }}</option>
|
||||
<option :value="'not_imported'">{{ $t('Not imported') }}</option>
|
||||
<option :value="'import_pending'">{{ $t('Import pending') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table v-if="result" class="ui compact very basic single line unstackable table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<div class="ui checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
@change="toggleCheckAll"
|
||||
:checked="result.results.length === checked.length"><label> </label>
|
||||
</div>
|
||||
</th>
|
||||
<i18next tag="th" path="Title"/>
|
||||
<i18next tag="th" path="Artist"/>
|
||||
<i18next tag="th" path="Album"/>
|
||||
<i18next tag="th" path="Published date"/>
|
||||
<i18next tag="th" v-if="showLibrary" path="Library"/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="track in result.results">
|
||||
<td class="collapsing">
|
||||
<div v-if="!track.local_track_file" class="ui checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
@change="toggleCheck(track.id)"
|
||||
:checked="checked.indexOf(track.id) > -1"><label> </label>
|
||||
</div>
|
||||
<div v-else class="ui label">
|
||||
<i18next path="In library"/>
|
||||
</div>
|
||||
<div class="dimmable">
|
||||
<div v-if="isLoading" class="ui active inverted dimmer">
|
||||
<div class="ui loader"></div>
|
||||
</div>
|
||||
<action-table
|
||||
v-if="result"
|
||||
@action-launched="fetchData"
|
||||
:objects-data="result"
|
||||
:actions="actions"
|
||||
:action-url="'federation/library-tracks/action/'"
|
||||
:filters="actionFilters">
|
||||
<template slot="header-cells">
|
||||
<th>{{ $t('Status') }}</th>
|
||||
<th>{{ $t('Title') }}</th>
|
||||
<th>{{ $t('Artist') }}</th>
|
||||
<th>{{ $t('Album') }}</th>
|
||||
<th>{{ $t('Published date') }}</th>
|
||||
<th v-if="showLibrary">{{ $t('Library') }}</th>
|
||||
</template>
|
||||
<template slot="action-success-footer" slot-scope="scope">
|
||||
<router-link
|
||||
v-if="scope.result.action === 'import'"
|
||||
:to="{name: 'library.import.batches.detail', params: {id: scope.result.result.batch.id }}">
|
||||
{{ $t('Import #{% id %} launched', {id: scope.result.result.batch.id}) }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template slot="row-cells" slot-scope="scope">
|
||||
<td>
|
||||
<span v-if="scope.obj.status === 'imported'" class="ui basic green label">{{ $t('In library') }}</span>
|
||||
<span v-else-if="scope.obj.status === 'import_pending'" class="ui basic yellow label">{{ $t('Import pending') }}</span>
|
||||
<span v-else class="ui basic label">{{ $t('Not imported') }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :title="track.title">{{ track.title|truncate(30) }}</span>
|
||||
<span :title="scope.obj.title">{{ scope.obj.title|truncate(30) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :title="track.artist_name">{{ track.artist_name|truncate(30) }}</span>
|
||||
<span :title="scope.obj.artist_name">{{ scope.obj.artist_name|truncate(30) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :title="track.album_title">{{ track.album_title|truncate(20) }}</span>
|
||||
<span :title="scope.obj.album_title">{{ scope.obj.album_title|truncate(20) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<human-date :date="track.published_date"></human-date>
|
||||
<human-date :date="scope.obj.published_date"></human-date>
|
||||
</td>
|
||||
<td v-if="showLibrary">
|
||||
{{ track.library.actor.domain }}
|
||||
{{ scope.obj.library.actor.domain }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot class="full-width">
|
||||
<tr>
|
||||
<th>
|
||||
<pagination
|
||||
v-if="result && result.results.length > 0"
|
||||
@page-changed="selectPage"
|
||||
:compact="true"
|
||||
:current="page"
|
||||
:paginate-by="paginateBy"
|
||||
:total="result.count"
|
||||
></pagination>
|
||||
</template>
|
||||
</action-table>
|
||||
</div>
|
||||
<div>
|
||||
<pagination
|
||||
v-if="result && result.results.length > 0"
|
||||
@page-changed="selectPage"
|
||||
:compact="true"
|
||||
:current="page"
|
||||
:paginate-by="paginateBy"
|
||||
:total="result.count"
|
||||
></pagination>
|
||||
|
||||
</th>
|
||||
<th v-if="result && result.results.length > 0">
|
||||
{{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}}
|
||||
<th>
|
||||
<button
|
||||
@click="launchImport"
|
||||
:disabled="checked.length === 0 || isImporting"
|
||||
:class="['ui', 'green', {loading: isImporting}, 'button']">
|
||||
{{ $t('Import {%count%} tracks', {'count': checked.length}) }}
|
||||
</button>
|
||||
<router-link
|
||||
v-if="importBatch"
|
||||
:to="{name: 'library.import.batches.detail', params: {id: importBatch.id }}">
|
||||
{{ $t('Import #{% id %} launched', {id: importBatch.id}) }}
|
||||
</router-link>
|
||||
</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th v-if="showLibrary"></th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<span v-if="result && result.results.length > 0">
|
||||
{{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -107,6 +89,7 @@ import axios from 'axios'
|
|||
import _ from 'lodash'
|
||||
|
||||
import Pagination from '@/components/Pagination'
|
||||
import ActionTable from '@/components/common/ActionTable'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
|
@ -114,7 +97,8 @@ export default {
|
|||
showLibrary: {type: Boolean, default: false}
|
||||
},
|
||||
components: {
|
||||
Pagination
|
||||
Pagination,
|
||||
ActionTable
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
@ -123,9 +107,6 @@ export default {
|
|||
page: 1,
|
||||
paginateBy: 25,
|
||||
search: '',
|
||||
checked: {},
|
||||
isImporting: false,
|
||||
importBatch: null,
|
||||
importedFilter: null
|
||||
}
|
||||
},
|
||||
|
@ -140,7 +121,7 @@ export default {
|
|||
'q': this.search
|
||||
}, this.filters)
|
||||
if (this.importedFilter !== null) {
|
||||
params.imported = this.importedFilter
|
||||
params.status = this.importedFilter
|
||||
}
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
|
@ -153,53 +134,41 @@ export default {
|
|||
self.errors = error.backendErrors
|
||||
})
|
||||
},
|
||||
launchImport () {
|
||||
let self = this
|
||||
self.isImporting = true
|
||||
let payload = {
|
||||
library_tracks: this.checked
|
||||
}
|
||||
axios.post('/submit/federation/', payload).then((response) => {
|
||||
self.importBatch = response.data
|
||||
self.isImporting = false
|
||||
self.fetchData()
|
||||
}, error => {
|
||||
self.isImporting = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
},
|
||||
toggleCheckAll () {
|
||||
if (this.checked.length === this.result.results.length) {
|
||||
// we uncheck
|
||||
this.checked = []
|
||||
} else {
|
||||
this.checked = this.result.results.filter(t => {
|
||||
return t.local_track_file === null
|
||||
}).map(t => { return t.id })
|
||||
}
|
||||
},
|
||||
toggleCheck (id) {
|
||||
if (this.checked.indexOf(id) > -1) {
|
||||
// we uncheck
|
||||
this.checked.splice(this.checked.indexOf(id), 1)
|
||||
} else {
|
||||
this.checked.push(id)
|
||||
}
|
||||
},
|
||||
selectPage: function (page) {
|
||||
this.page = page
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
actionFilters () {
|
||||
var currentFilters = {
|
||||
q: this.search
|
||||
}
|
||||
if (this.filters) {
|
||||
return _.merge(currentFilters, this.filters)
|
||||
} else {
|
||||
return currentFilters
|
||||
}
|
||||
},
|
||||
actions () {
|
||||
return [
|
||||
{
|
||||
name: 'import',
|
||||
label: this.$t('Import'),
|
||||
filterCheckable: (obj) => { return obj.status === 'not_imported' }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
search (newValue) {
|
||||
if (newValue.length > 0) {
|
||||
this.fetchData()
|
||||
}
|
||||
this.page = 1
|
||||
this.fetchData()
|
||||
},
|
||||
page () {
|
||||
this.fetchData()
|
||||
},
|
||||
importedFilter () {
|
||||
this.page = 1
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
<i class="circular inverted users violet icon"></i>
|
||||
<div class="content">
|
||||
{{ artist.name }}
|
||||
<div class="sub header">
|
||||
<div class="sub header" v-if="albums">
|
||||
{{ $t('{% track_count %} tracks in {% album_count %} albums', {track_count: totalTracks, album_count: albums.length})}}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -18,7 +18,7 @@
|
|||
<div class="ui hidden divider"></div>
|
||||
<radio-button type="artist" :object-id="artist.id"></radio-button>
|
||||
</button>
|
||||
<play-button class="orange" :tracks="allTracks"><i18next path="Play all albums"/></play-button>
|
||||
<play-button class="orange" :artist="artist.id"><i18next path="Play all albums"/></play-button>
|
||||
|
||||
<a :href="wikipediaUrl" target="_blank" class="ui button">
|
||||
<i class="wikipedia icon"></i>
|
||||
|
@ -30,10 +30,13 @@
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<div v-if="isLoadingAlbums" class="ui vertical stripe segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<div v-else-if="albums" class="ui vertical stripe segment">
|
||||
<h2><i18next path="Albums by this artist"/></h2>
|
||||
<div class="ui stackable doubling three column grid">
|
||||
<div class="column" :key="album.id" v-for="album in sortedAlbums">
|
||||
<div class="column" :key="album.id" v-for="album in albums">
|
||||
<album-card :mode="'rich'" class="fluid" :album="album"></album-card>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -43,7 +46,6 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import _ from 'lodash'
|
||||
import axios from 'axios'
|
||||
import logger from '@/logging'
|
||||
import backend from '@/audio/backend'
|
||||
|
@ -63,6 +65,7 @@ export default {
|
|||
data () {
|
||||
return {
|
||||
isLoading: true,
|
||||
isLoadingAlbums: true,
|
||||
artist: null,
|
||||
albums: null
|
||||
}
|
||||
|
@ -78,18 +81,19 @@ export default {
|
|||
logger.default.debug('Fetching artist "' + this.id + '"')
|
||||
axios.get(url).then((response) => {
|
||||
self.artist = response.data
|
||||
self.albums = JSON.parse(JSON.stringify(self.artist.albums)).map((album) => {
|
||||
return backend.Album.clean(album)
|
||||
})
|
||||
self.isLoading = false
|
||||
self.isLoadingAlbums = true
|
||||
axios.get('albums/', {params: {artist: this.id, ordering: '-release_date'}}).then((response) => {
|
||||
self.albums = JSON.parse(JSON.stringify(response.data.results)).map((album) => {
|
||||
return backend.Album.clean(album)
|
||||
})
|
||||
|
||||
self.isLoadingAlbums = false
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sortedAlbums () {
|
||||
let a = this.albums || []
|
||||
return _.orderBy(a, ['release_date'], ['asc'])
|
||||
},
|
||||
totalTracks () {
|
||||
return this.albums.map((album) => {
|
||||
return album.tracks.length
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
<div class="field">
|
||||
<i18next tag="label" path="Ordering direction"/>
|
||||
<select class="ui dropdown" v-model="orderingDirection">
|
||||
<option value="">Ascending</option>
|
||||
<option value="+">Ascending</option>
|
||||
<option value="-">Descending</option>
|
||||
</select>
|
||||
</div>
|
||||
|
@ -69,7 +69,6 @@ import axios from 'axios'
|
|||
import _ from 'lodash'
|
||||
import $ from 'jquery'
|
||||
|
||||
import backend from '@/audio/backend'
|
||||
import logger from '@/logging'
|
||||
|
||||
import OrderingMixin from '@/components/mixins/Ordering'
|
||||
|
@ -96,7 +95,7 @@ export default {
|
|||
page: parseInt(this.defaultPage),
|
||||
query: this.defaultQuery,
|
||||
paginateBy: parseInt(this.defaultPaginateBy || 12),
|
||||
orderingDirection: defaultOrdering.direction,
|
||||
orderingDirection: defaultOrdering.direction || '+',
|
||||
ordering: defaultOrdering.field,
|
||||
orderingOptions: [
|
||||
['creation_date', 'Creation date'],
|
||||
|
@ -135,13 +134,6 @@ export default {
|
|||
logger.default.debug('Fetching artists')
|
||||
axios.get(url, {params: params}).then((response) => {
|
||||
self.result = response.data
|
||||
self.result.results.map((artist) => {
|
||||
var albums = JSON.parse(JSON.stringify(artist.albums)).map((album) => {
|
||||
return backend.Album.clean(album)
|
||||
})
|
||||
artist.albums = albums
|
||||
return artist
|
||||
})
|
||||
self.isLoading = false
|
||||
})
|
||||
}, 500),
|
||||
|
|
|
@ -30,7 +30,6 @@
|
|||
<script>
|
||||
import axios from 'axios'
|
||||
import Search from '@/components/audio/Search'
|
||||
import backend from '@/audio/backend'
|
||||
import logger from '@/logging'
|
||||
import ArtistCard from '@/components/audio/artist/Card'
|
||||
import RadioCard from '@/components/radios/Card'
|
||||
|
@ -66,13 +65,6 @@ export default {
|
|||
logger.default.time('Loading latest artists')
|
||||
axios.get(url, {params: params}).then((response) => {
|
||||
self.artists = response.data.results
|
||||
self.artists.map((artist) => {
|
||||
var albums = JSON.parse(JSON.stringify(artist.albums)).map((album) => {
|
||||
return backend.Album.clean(album)
|
||||
})
|
||||
artist.albums = albums
|
||||
return artist
|
||||
})
|
||||
logger.default.timeEnd('Loading latest artists')
|
||||
self.isLoadingArtists = false
|
||||
})
|
||||
|
|
|
@ -13,10 +13,10 @@
|
|||
exact>
|
||||
<i18next path="Requests"/>
|
||||
</router-link>
|
||||
<router-link v-if="$store.state.auth.availablePermissions['library']" class="ui item" to="/library/import/launch" exact>
|
||||
<router-link v-if="showImports" class="ui item" to="/library/import/launch" exact>
|
||||
<i18next path="Import"/>
|
||||
</router-link>
|
||||
<router-link v-if="$store.state.auth.availablePermissions['library']" class="ui item" to="/library/import/batches">
|
||||
<router-link v-if="showImports" class="ui item" to="/library/import/batches">
|
||||
<i18next path="Import batches"/>
|
||||
</router-link>
|
||||
</div>
|
||||
|
@ -27,7 +27,11 @@
|
|||
|
||||
<script>
|
||||
export default {
|
||||
name: 'library'
|
||||
computed: {
|
||||
showImports () {
|
||||
return this.$store.state.auth.availablePermissions['upload'] || this.$store.state.auth.availablePermissions['library']
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
<div class="field">
|
||||
<i18next tag="label" path="Ordering direction"/>
|
||||
<select class="ui dropdown" v-model="orderingDirection">
|
||||
<option value=""><i18next path="Ascending"/></option>
|
||||
<option value="+"><i18next path="Ascending"/></option>
|
||||
<option value="-"><i18next path="Descending"/></option>
|
||||
</select>
|
||||
</div>
|
||||
|
@ -99,7 +99,7 @@ export default {
|
|||
page: parseInt(this.defaultPage),
|
||||
query: this.defaultQuery,
|
||||
paginateBy: parseInt(this.defaultPaginateBy || 12),
|
||||
orderingDirection: defaultOrdering.direction,
|
||||
orderingDirection: defaultOrdering.direction || '+',
|
||||
ordering: defaultOrdering.field,
|
||||
orderingOptions: [
|
||||
['creation_date', 'Creation date'],
|
||||
|
|
|
@ -14,8 +14,7 @@
|
|||
<i18next path="From album {%0%} by {%1%}">
|
||||
<router-link :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
|
||||
{{ track.album.title }}
|
||||
</router-link>
|
||||
<router-link :to="{name: 'library.artists.detail', params: {id: track.artist.id }}">
|
||||
</router-link><router-link :to="{name: 'library.artists.detail', params: {id: track.artist.id }}">
|
||||
{{ track.artist.name }}
|
||||
</router-link>
|
||||
</i18next>
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="batch" class="ui container">
|
||||
<div class="ui message">
|
||||
{{ $t('Ensure your music files are properly tagged before uploading them.') }}
|
||||
<a href="http://picard.musicbrainz.org/" target='_blank'>{{ $t('We recommend using Picard for that purpose.') }}</a>
|
||||
</div>
|
||||
<file-upload-widget
|
||||
:class="['ui', 'icon', 'left', 'floated', 'button']"
|
||||
:post-action="uploadUrl"
|
||||
|
@ -8,7 +12,7 @@
|
|||
:size="1024 * 1024 * 30"
|
||||
:data="uploadData"
|
||||
:drop="true"
|
||||
extensions="ogg,mp3"
|
||||
extensions="ogg,mp3,flac"
|
||||
accept="audio/*"
|
||||
v-model="files"
|
||||
name="audio_file"
|
||||
|
@ -21,7 +25,7 @@
|
|||
</file-upload-widget>
|
||||
<button
|
||||
:class="['ui', 'right', 'floated', 'icon', {disabled: files.length === 0}, 'button']"
|
||||
v-if="!$refs.upload || !$refs.upload.active" @click.prevent="$refs.upload.active = true">
|
||||
v-if="!$refs.upload || !$refs.upload.active" @click.prevent="startUpload()">
|
||||
<i class="play icon" aria-hidden="true"></i>
|
||||
<i18next path="Start Upload"/>
|
||||
</button>
|
||||
|
@ -88,7 +92,7 @@ export default {
|
|||
inputFilter (newFile, oldFile, prevent) {
|
||||
if (newFile && !oldFile) {
|
||||
let extension = newFile.name.split('.').pop()
|
||||
if (['ogg', 'mp3'].indexOf(extension) < 0) {
|
||||
if (['ogg', 'mp3', 'flac'].indexOf(extension) < 0) {
|
||||
prevent()
|
||||
}
|
||||
}
|
||||
|
@ -114,6 +118,10 @@ export default {
|
|||
}, (response) => {
|
||||
logger.default.error('error while launching creating batch')
|
||||
})
|
||||
},
|
||||
startUpload () {
|
||||
this.$emit('batch-created', this.batch)
|
||||
this.$refs.upload.active = true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
|
@ -24,16 +24,25 @@
|
|||
<div class="ui hidden divider"></div>
|
||||
<div class="ui centered buttons">
|
||||
<button @click="currentStep -= 1" :disabled="currentStep === 0" class="ui icon button"><i class="left arrow icon"></i><i18next path="Previous step"/></button>
|
||||
<button @click="currentStep += 1" v-if="currentStep < 2" class="ui icon button"><i18next path="Next step"/><i class="right arrow icon"></i></button>
|
||||
<button @click="nextStep()" v-if="currentStep < 2" class="ui icon button"><i18next path="Next step"/><i class="right arrow icon"></i></button>
|
||||
<button
|
||||
@click="$refs.import.launchImport()"
|
||||
v-if="currentStep === 2"
|
||||
v-if="currentStep === 2 && currentSource != 'upload'"
|
||||
:class="['ui', 'positive', 'icon', {'loading': isImporting}, 'button']"
|
||||
:disabled="isImporting || importData.count === 0"
|
||||
>
|
||||
<i18next path="Import {%0%} tracks">{{ importData.count }}</i18next>
|
||||
<i class="check icon"></i>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="currentStep === 2 && currentSource === 'upload'"
|
||||
@click="$router.push({name: 'library.import.batches.detail', params: {id: importBatch.id}})"
|
||||
:class="['ui', 'positive', 'icon', {'disabled': !importBatch}, 'button']"
|
||||
:disabled="!importBatch"
|
||||
>
|
||||
{{ $t('Finish import' )}}
|
||||
<i class="check icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ui hidden divider"></div>
|
||||
<div class="ui attached segment">
|
||||
|
@ -100,6 +109,7 @@
|
|||
<div v-if="currentStep === 2">
|
||||
<file-upload
|
||||
ref="import"
|
||||
@batch-created="updateBatch"
|
||||
v-if="currentSource == 'upload'"
|
||||
></file-upload>
|
||||
|
||||
|
@ -165,6 +175,7 @@ export default {
|
|||
currentSource: this.source,
|
||||
metadata: {},
|
||||
isImporting: false,
|
||||
importBatch: null,
|
||||
importData: {
|
||||
tracks: []
|
||||
},
|
||||
|
@ -214,11 +225,22 @@ export default {
|
|||
updateId (newValue) {
|
||||
this.currentId = newValue
|
||||
},
|
||||
updateBatch (batch) {
|
||||
this.importBatch = batch
|
||||
},
|
||||
fetchRequest (id) {
|
||||
let self = this
|
||||
axios.get(`requests/import-requests/${id}`).then((response) => {
|
||||
self.currentRequest = response.data
|
||||
})
|
||||
},
|
||||
nextStep () {
|
||||
if (this.currentStep === 0 && this.currentSource === 'upload') {
|
||||
// we skip metadata directly
|
||||
this.currentStep += 2
|
||||
} else {
|
||||
this.currentStep += 1
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue