From 99c02b4f7e52b651361b48c94c1821f86dea2d87 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Tue, 8 May 2018 16:30:42 +0200 Subject: [PATCH 01/15] Can now serve front end via IP --- dev.yml | 2 +- docker/traefik.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev.yml b/dev.yml index 534d8f5b5..e85ce3b91 100644 --- a/dev.yml +++ b/dev.yml @@ -20,7 +20,7 @@ services: - internal labels: traefik.backend: "${COMPOSE_PROJECT_NAME-node1}" - traefik.frontend.rule: "Host: ${COMPOSE_PROJECT_NAME-node1}.funkwhale.test" + traefik.frontend.rule: "Host:${COMPOSE_PROJECT_NAME-node1}.funkwhale.test,${NODE_IP-127.0.0.1}" traefik.enable: 'true' traefik.federation.protocol: 'http' traefik.federation.port: "${WEBPACK_DEVSERVER_PORT-8080}" diff --git a/docker/traefik.toml b/docker/traefik.toml index 85da2ea72..c87f4527d 100644 --- a/docker/traefik.toml +++ b/docker/traefik.toml @@ -1,5 +1,5 @@ defaultEntryPoints = ["http", "https"] - +[accessLog] ################################################################ # Web configuration backend ################################################################ From 96822994807bc3d2988e7d45ce685d85992501ef Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Tue, 8 May 2018 16:31:19 +0200 Subject: [PATCH 02/15] See #75: dedicated token for subsonic API access --- api/funkwhale_api/users/factories.py | 1 + .../migrations/0005_user_subsonic_api_token.py | 18 ++++++++++++++++++ api/funkwhale_api/users/models.py | 14 ++++++++++++++ api/tests/users/test_models.py | 14 ++++++++++++++ 4 files changed, 47 insertions(+) create mode 100644 api/funkwhale_api/users/migrations/0005_user_subsonic_api_token.py diff --git a/api/funkwhale_api/users/factories.py b/api/funkwhale_api/users/factories.py index 0af155e77..12307f7fd 100644 --- a/api/funkwhale_api/users/factories.py +++ b/api/funkwhale_api/users/factories.py @@ -9,6 +9,7 @@ class UserFactory(factory.django.DjangoModelFactory): username = factory.Sequence(lambda n: 'user-{0}'.format(n)) email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n)) password = factory.PostGenerationMethodCall('set_password', 'test') + subsonic_api_token = None class Meta: model = 'users.User' diff --git a/api/funkwhale_api/users/migrations/0005_user_subsonic_api_token.py b/api/funkwhale_api/users/migrations/0005_user_subsonic_api_token.py new file mode 100644 index 000000000..689b3ef77 --- /dev/null +++ b/api/funkwhale_api/users/migrations/0005_user_subsonic_api_token.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.3 on 2018-05-08 09:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0004_user_privacy_level'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='subsonic_api_token', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 572fa9ddc..773d60f38 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals, absolute_import import uuid +import secrets from django.conf import settings from django.contrib.auth.models import AbstractUser @@ -38,6 +39,13 @@ class User(AbstractUser): privacy_level = fields.get_privacy_field() + # Unfortunately, Subsonic API assumes a MD5/password authentication + # scheme, which is weak in terms of security, and not achievable + # anyway since django use stronger schemes for storing passwords. + # Users that want to use the subsonic API from external client + # should set this token and use it as their password in such clients + subsonic_api_token = models.CharField( + blank=True, null=True, max_length=255) def __str__(self): return self.username @@ -49,9 +57,15 @@ class User(AbstractUser): self.secret_key = uuid.uuid4() return self.secret_key + def update_subsonic_api_token(self): + self.subsonic_api_token = secrets.token_hex(32) + return self.subsonic_api_token + def set_password(self, raw_password): super().set_password(raw_password) self.update_secret_key() + if self.subsonic_api_token: + self.update_subsonic_api_token() def get_activity_url(self): return settings.FUNKWHALE_URL + '/@{}'.format(self.username) diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py index 57793f494..c7cd12e9e 100644 --- a/api/tests/users/test_models.py +++ b/api/tests/users/test_models.py @@ -2,3 +2,17 @@ def test__str__(factories): user = factories['users.User'](username='hello') assert user.__str__() == 'hello' + + +def test_changing_password_updates_subsonic_api_token_no_token(factories): + user = factories['users.User'](subsonic_api_token=None) + user.set_password('new') + assert user.subsonic_api_token is None + + +def test_changing_password_updates_subsonic_api_token(factories): + user = factories['users.User'](subsonic_api_token='test') + user.set_password('new') + + assert user.subsonic_api_token is not None + assert user.subsonic_api_token != 'test' From bbd273404aad600c9078a651c4498bcd9bbfce32 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Tue, 8 May 2018 16:32:07 +0200 Subject: [PATCH 03/15] See #75: initial subsonic implementation that works with http://p.subfireplayer.net --- api/config/api_urls.py | 8 +- api/funkwhale_api/music/factories.py | 2 +- api/funkwhale_api/music/models.py | 8 +- api/funkwhale_api/music/views.py | 93 ++++++------ api/funkwhale_api/subsonic/__init__.py | 0 api/funkwhale_api/subsonic/authentication.py | 69 +++++++++ api/funkwhale_api/subsonic/negotiation.py | 21 +++ api/funkwhale_api/subsonic/renderers.py | 48 +++++++ api/funkwhale_api/subsonic/serializers.py | 100 +++++++++++++ api/funkwhale_api/subsonic/views.py | 143 +++++++++++++++++++ api/tests/conftest.py | 1 + api/tests/subsonic/test_authentication.py | 56 ++++++++ api/tests/subsonic/test_renderers.py | 44 ++++++ api/tests/subsonic/test_serializers.py | 109 ++++++++++++++ api/tests/subsonic/test_views.py | 120 ++++++++++++++++ 15 files changed, 774 insertions(+), 48 deletions(-) create mode 100644 api/funkwhale_api/subsonic/__init__.py create mode 100644 api/funkwhale_api/subsonic/authentication.py create mode 100644 api/funkwhale_api/subsonic/negotiation.py create mode 100644 api/funkwhale_api/subsonic/renderers.py create mode 100644 api/funkwhale_api/subsonic/serializers.py create mode 100644 api/funkwhale_api/subsonic/views.py create mode 100644 api/tests/subsonic/test_authentication.py create mode 100644 api/tests/subsonic/test_renderers.py create mode 100644 api/tests/subsonic/test_serializers.py create mode 100644 api/tests/subsonic/test_views.py diff --git a/api/config/api_urls.py b/api/config/api_urls.py index cf5b03744..e75781d14 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -1,9 +1,11 @@ from rest_framework import routers +from rest_framework.urlpatterns import format_suffix_patterns from django.conf.urls import include, url from funkwhale_api.activity import views as activity_views from funkwhale_api.instance import views as instance_views from funkwhale_api.music import views from funkwhale_api.playlists import views as playlists_views +from funkwhale_api.subsonic.views import SubsonicViewSet from rest_framework_jwt import views as jwt_views from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet @@ -27,6 +29,10 @@ router.register( 'playlist-tracks') v1_patterns = router.urls +subsonic_router = routers.SimpleRouter(trailing_slash=False) +subsonic_router.register(r'subsonic/rest', SubsonicViewSet, base_name='subsonic') + + v1_patterns += [ url(r'^instance/', include( @@ -68,4 +74,4 @@ v1_patterns += [ urlpatterns = [ url(r'^v1/', include((v1_patterns, 'v1'), namespace='v1')) -] +] + format_suffix_patterns(subsonic_router.urls, allowed=['view']) diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py index bc0c74a2d..1df949904 100644 --- a/api/funkwhale_api/music/factories.py +++ b/api/funkwhale_api/music/factories.py @@ -26,7 +26,7 @@ class ArtistFactory(factory.django.DjangoModelFactory): class AlbumFactory(factory.django.DjangoModelFactory): title = factory.Faker('sentence', nb_words=3) mbid = factory.Faker('uuid4') - release_date = factory.Faker('date') + release_date = factory.Faker('date_object') cover = factory.django.ImageField() artist = factory.SubFactory(ArtistFactory) release_group_id = factory.Faker('uuid4') diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 655d38755..e6253eb92 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -457,7 +457,13 @@ class TrackFile(models.Model): def filename(self): return '{}{}'.format( self.track.full_name, - os.path.splitext(self.audio_file.name)[-1]) + self.extension) + + @property + def extension(self): + if not self.audio_file: + return + return os.path.splitext(self.audio_file.name)[-1].replace('.', '', 1) def save(self, **kwargs): if not self.mimetype and self.audio_file: diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 76fc8bc3e..98274e741 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -245,6 +245,53 @@ def get_file_path(audio_file): return path +def handle_serve(track_file): + f = track_file + # we update the accessed_date + f.accessed_date = timezone.now() + f.save(update_fields=['accessed_date']) + + mt = f.mimetype + audio_file = f.audio_file + try: + library_track = f.library_track + except ObjectDoesNotExist: + library_track = None + if library_track and not audio_file: + if not library_track.audio_file: + # we need to populate from cache + with transaction.atomic(): + # why the transaction/select_for_update? + # this is because browsers may send multiple requests + # in a short time range, for partial content, + # thus resulting in multiple downloads from the remote + qs = LibraryTrack.objects.select_for_update() + library_track = qs.get(pk=library_track.pk) + library_track.download_audio() + audio_file = library_track.audio_file + file_path = get_file_path(audio_file) + mt = library_track.audio_mimetype + elif audio_file: + file_path = get_file_path(audio_file) + elif f.source and f.source.startswith('file://'): + file_path = get_file_path(f.source.replace('file://', '', 1)) + response = Response() + filename = f.filename + mapping = { + 'nginx': 'X-Accel-Redirect', + 'apache2': 'X-Sendfile', + } + file_header = mapping[settings.REVERSE_PROXY_TYPE] + response[file_header] = file_path + filename = "filename*=UTF-8''{}".format( + urllib.parse.quote(filename)) + response["Content-Disposition"] = "attachment; {}".format(filename) + if mt: + response["Content-Type"] = mt + + return response + + class TrackFileViewSet(viewsets.ReadOnlyModelViewSet): queryset = (models.TrackFile.objects.all().order_by('-id')) serializer_class = serializers.TrackFileSerializer @@ -261,54 +308,10 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet): 'track__artist', ) try: - f = queryset.get(pk=kwargs['pk']) + return handle_serve(queryset.get(pk=kwargs['pk'])) except models.TrackFile.DoesNotExist: return Response(status=404) - # we update the accessed_date - f.accessed_date = timezone.now() - f.save(update_fields=['accessed_date']) - - mt = f.mimetype - audio_file = f.audio_file - try: - library_track = f.library_track - except ObjectDoesNotExist: - library_track = None - if library_track and not audio_file: - if not library_track.audio_file: - # we need to populate from cache - with transaction.atomic(): - # why the transaction/select_for_update? - # this is because browsers may send multiple requests - # in a short time range, for partial content, - # thus resulting in multiple downloads from the remote - qs = LibraryTrack.objects.select_for_update() - library_track = qs.get(pk=library_track.pk) - library_track.download_audio() - audio_file = library_track.audio_file - file_path = get_file_path(audio_file) - mt = library_track.audio_mimetype - elif audio_file: - file_path = get_file_path(audio_file) - elif f.source and f.source.startswith('file://'): - file_path = get_file_path(f.source.replace('file://', '', 1)) - response = Response() - filename = f.filename - mapping = { - 'nginx': 'X-Accel-Redirect', - 'apache2': 'X-Sendfile', - } - file_header = mapping[settings.REVERSE_PROXY_TYPE] - response[file_header] = file_path - filename = "filename*=UTF-8''{}".format( - urllib.parse.quote(filename)) - response["Content-Disposition"] = "attachment; {}".format(filename) - if mt: - response["Content-Type"] = mt - - return response - @list_route(methods=['get']) def viewable(self, request, *args, **kwargs): return Response({}, status=200) diff --git a/api/funkwhale_api/subsonic/__init__.py b/api/funkwhale_api/subsonic/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/funkwhale_api/subsonic/authentication.py b/api/funkwhale_api/subsonic/authentication.py new file mode 100644 index 000000000..fe9b08dc8 --- /dev/null +++ b/api/funkwhale_api/subsonic/authentication.py @@ -0,0 +1,69 @@ +import binascii +import hashlib + +from rest_framework import authentication +from rest_framework import exceptions + +from funkwhale_api.users.models import User + + +def get_token(salt, password): + to_hash = password + salt + h = hashlib.md5() + h.update(to_hash.encode('utf-8')) + return h.hexdigest() + + +def authenticate(username, password): + try: + if password.startswith('enc:'): + password = password.replace('enc:', '', 1) + password = binascii.unhexlify(password).decode('utf-8') + user = User.objects.get( + username=username, + is_active=True, + subsonic_api_token=password) + except (User.DoesNotExist, binascii.Error): + raise exceptions.AuthenticationFailed( + 'Wrong username or password.' + ) + + return (user, None) + + +def authenticate_salt(username, salt, token): + try: + user = User.objects.get( + username=username, + is_active=True, + subsonic_api_token__isnull=False) + except User.DoesNotExist: + raise exceptions.AuthenticationFailed( + 'Wrong username or password.' + ) + expected = get_token(salt, user.subsonic_api_token) + if expected != token: + raise exceptions.AuthenticationFailed( + 'Wrong username or password.' + ) + + return (user, None) + + +class SubsonicAuthentication(authentication.BaseAuthentication): + def authenticate(self, request): + data = request.GET or request.POST + username = data.get('u') + if not username: + return None + + p = data.get('p') + s = data.get('s') + t = data.get('t') + if not p and (not s or not t): + raise exceptions.AuthenticationFailed('Missing credentials') + + if p: + return authenticate(username, p) + + return authenticate_salt(username, s, t) diff --git a/api/funkwhale_api/subsonic/negotiation.py b/api/funkwhale_api/subsonic/negotiation.py new file mode 100644 index 000000000..3335fda45 --- /dev/null +++ b/api/funkwhale_api/subsonic/negotiation.py @@ -0,0 +1,21 @@ +from rest_framework import exceptions +from rest_framework import negotiation + +from . import renderers + + +MAPPING = { + 'json': (renderers.SubsonicJSONRenderer(), 'application/json'), + 'xml': (renderers.SubsonicXMLRenderer(), 'text/xml'), +} + + +class SubsonicContentNegociation(negotiation.DefaultContentNegotiation): + def select_renderer(self, request, renderers, format_suffix=None): + path = request.path + data = request.GET or request.POST + requested_format = data.get('f', 'xml') + try: + return MAPPING[requested_format] + except KeyError: + raise exceptions.NotAcceptable(available_renderers=renderers) diff --git a/api/funkwhale_api/subsonic/renderers.py b/api/funkwhale_api/subsonic/renderers.py new file mode 100644 index 000000000..74cf13d88 --- /dev/null +++ b/api/funkwhale_api/subsonic/renderers.py @@ -0,0 +1,48 @@ +import xml.etree.ElementTree as ET + +from rest_framework import renderers + + +class SubsonicJSONRenderer(renderers.JSONRenderer): + def render(self, data, accepted_media_type=None, renderer_context=None): + if not data: + # when stream view is called, we don't have any data + return super().render(data, accepted_media_type, renderer_context) + final = { + 'subsonic-response': { + 'status': 'ok', + 'version': '1.16.0', + } + } + final['subsonic-response'].update(data) + return super().render(final, accepted_media_type, renderer_context) + + +class SubsonicXMLRenderer(renderers.JSONRenderer): + media_type = 'text/xml' + + def render(self, data, accepted_media_type=None, renderer_context=None): + if not data: + # when stream view is called, we don't have any data + return super().render(data, accepted_media_type, renderer_context) + final = { + 'xmlns': 'http://subsonic.org/restapi', + 'status': 'ok', + 'version': '1.16.0', + } + final.update(data) + tree = dict_to_xml_tree('subsonic-response', final) + return b'\n' + ET.tostring(tree, encoding='utf-8') + + +def dict_to_xml_tree(root_tag, d, parent=None): + root = ET.Element(root_tag) + for key, value in d.items(): + if isinstance(value, dict): + root.append(dict_to_xml_tree(key, value, parent=root)) + elif isinstance(value, list): + for obj in value: + root.append(dict_to_xml_tree(key, obj, parent=root)) + else: + root.set(key, str(value)) + return root diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py new file mode 100644 index 000000000..034343be0 --- /dev/null +++ b/api/funkwhale_api/subsonic/serializers.py @@ -0,0 +1,100 @@ +import collections + +from django.db.models import functions, Count + +from rest_framework import serializers + + +class GetArtistsSerializer(serializers.Serializer): + def to_representation(self, queryset): + payload = { + 'ignoredArticles': '', + 'index': [] + } + queryset = queryset.annotate(_albums_count=Count('albums')) + queryset = queryset.order_by(functions.Lower('name')) + values = queryset.values('id', '_albums_count', 'name') + + first_letter_mapping = collections.defaultdict(list) + for artist in values: + first_letter_mapping[artist['name'][0].upper()].append(artist) + + for letter, artists in sorted(first_letter_mapping.items()): + letter_data = { + 'name': letter, + 'artist': [ + { + 'id': v['id'], + 'name': v['name'], + 'albumCount': v['_albums_count'] + } + for v in artists + ] + } + payload['index'].append(letter_data) + return payload + + +class GetArtistSerializer(serializers.Serializer): + def to_representation(self, artist): + albums = artist.albums.prefetch_related('tracks__files') + payload = { + 'id': artist.pk, + 'name': artist.name, + 'albumCount': len(albums), + 'album': [], + } + for album in albums: + album_data = { + 'id': album.id, + 'artistId': artist.id, + 'name': album.title, + 'artist': artist.name, + 'created': album.creation_date, + 'songCount': len(album.tracks.all()) + } + if album.release_date: + album_data['year'] = album.release_date.year + payload['album'].append(album_data) + return payload + + +class GetAlbumSerializer(serializers.Serializer): + def to_representation(self, album): + tracks = album.tracks.prefetch_related('files') + payload = { + 'id': album.id, + 'artistId': album.artist.id, + 'name': album.title, + 'artist': album.artist.name, + 'created': album.creation_date, + 'songCount': len(tracks), + 'song': [], + } + if album.release_date: + payload['year'] = album.release_date.year + + for track in tracks: + try: + tf = [tf for tf in track.files.all()][0] + except IndexError: + continue + track_data = { + 'id': track.pk, + 'isDir': False, + 'title': track.title, + 'album': album.title, + 'artist': album.artist.name, + 'track': track.position, + 'contentType': tf.mimetype, + 'suffix': tf.extension, + 'duration': tf.duration, + 'created': track.creation_date, + 'albumId': album.pk, + 'artistId': album.artist.pk, + 'type': 'music', + } + if album.release_date: + track_data['year'] = album.release_date.year + payload['song'].append(track_data) + return payload diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py new file mode 100644 index 000000000..98fea7598 --- /dev/null +++ b/api/funkwhale_api/subsonic/views.py @@ -0,0 +1,143 @@ +from rest_framework import exceptions +from rest_framework import permissions as rest_permissions +from rest_framework import response +from rest_framework import viewsets +from rest_framework.decorators import list_route +from rest_framework.serializers import ValidationError + +from funkwhale_api.music import models as music_models +from funkwhale_api.music import views as music_views + +from . import authentication +from . import negotiation +from . import serializers + + +def find_object(queryset, model_field='pk', field='id', cast=int): + def decorator(func): + def inner(self, request, *args, **kwargs): + data = request.GET or request.POST + try: + raw_value = data[field] + except KeyError: + return response.Response({ + 'code': 10, + 'message': "required parameter '{}' not present".format(field) + }) + try: + value = cast(raw_value) + except (TypeError, ValidationError): + return response.Response({ + 'code': 0, + 'message': 'For input string "{}"'.format(raw_value) + }) + try: + obj = queryset.get(**{model_field: value}) + except queryset.model.DoesNotExist: + return response.Response({ + 'code': 70, + 'message': '{} not found'.format( + queryset.model.__class__.__name__) + }) + kwargs['obj'] = obj + return func(self, request, *args, **kwargs) + return inner + return decorator + + +class SubsonicViewSet(viewsets.GenericViewSet): + content_negotiation_class = negotiation.SubsonicContentNegociation + authentication_classes = [authentication.SubsonicAuthentication] + permissions_classes = [rest_permissions.IsAuthenticated] + + def handle_exception(self, exc): + # subsonic API sends 200 status code with custom error + # codes in the payload + mapping = { + exceptions.AuthenticationFailed: ( + 40, 'Wrong username or password.' + ) + } + payload = { + 'status': 'failed' + } + try: + code, message = mapping[exc.__class__] + except KeyError: + return super().handle_exception(exc) + else: + payload['error'] = { + 'code': code, + 'message': message + } + + return response.Response(payload, status=200) + + @list_route( + methods=['get', 'post'], + permission_classes=[]) + def ping(self, request, *args, **kwargs): + data = { + 'status': 'ok', + 'version': '1.16.0' + } + return response.Response(data, status=200) + + @list_route( + methods=['get', 'post'], + url_name='get_artists', + url_path='getArtists') + def get_artists(self, request, *args, **kwargs): + artists = music_models.Artist.objects.all() + data = serializers.GetArtistsSerializer(artists).data + payload = { + 'artists': data + } + + return response.Response(payload, status=200) + + @list_route( + methods=['get', 'post'], + url_name='get_artist', + url_path='getArtist') + @find_object(music_models.Artist.objects.all()) + def get_artist(self, request, *args, **kwargs): + artist = kwargs.pop('obj') + data = serializers.GetArtistSerializer(artist).data + payload = { + 'artist': data + } + + return response.Response(payload, status=200) + + @list_route( + methods=['get', 'post'], + url_name='get_album', + url_path='getAlbum') + @find_object( + music_models.Album.objects.select_related('artist')) + def get_album(self, request, *args, **kwargs): + album = kwargs.pop('obj') + data = serializers.GetAlbumSerializer(album).data + payload = { + 'album': data + } + return response.Response(payload, status=200) + + @list_route( + methods=['get', 'post'], + url_name='stream', + url_path='stream') + @find_object( + music_models.Track.objects.all()) + def stream(self, request, *args, **kwargs): + track = kwargs.pop('obj') + queryset = track.files.select_related( + 'library_track', + 'track__album__artist', + 'track__artist', + ) + track_file = queryset.first() + if not track_file: + return Response(status=404) + return music_views.handle_serve(track_file) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 51a1bc4c7..dda537801 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -130,6 +130,7 @@ def logged_in_api_client(db, factories, api_client): """ user = factories['users.User']() assert api_client.login(username=user.username, password='test') + api_client.force_authenticate(user=user) setattr(api_client, 'user', user) yield api_client delattr(api_client, 'user') diff --git a/api/tests/subsonic/test_authentication.py b/api/tests/subsonic/test_authentication.py new file mode 100644 index 000000000..724513523 --- /dev/null +++ b/api/tests/subsonic/test_authentication.py @@ -0,0 +1,56 @@ +import binascii + +from funkwhale_api.subsonic import authentication + + +def test_auth_with_salt(api_request, factories): + salt = 'salt' + user = factories['users.User']() + user.subsonic_api_token = 'password' + user.save() + token = authentication.get_token(salt, 'password') + request = api_request.get('/', { + 't': token, + 's': salt, + 'u': user.username + }) + + authenticator = authentication.SubsonicAuthentication() + u, _ = authenticator.authenticate(request) + + assert user == u + + +def test_auth_with_password_hex(api_request, factories): + salt = 'salt' + user = factories['users.User']() + user.subsonic_api_token = 'password' + user.save() + token = authentication.get_token(salt, 'password') + request = api_request.get('/', { + 'u': user.username, + 'p': 'enc:{}'.format(binascii.hexlify( + user.subsonic_api_token.encode('utf-8')).decode('utf-8')) + }) + + authenticator = authentication.SubsonicAuthentication() + u, _ = authenticator.authenticate(request) + + assert user == u + + +def test_auth_with_password_cleartext(api_request, factories): + salt = 'salt' + user = factories['users.User']() + 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() + u, _ = authenticator.authenticate(request) + + assert user == u diff --git a/api/tests/subsonic/test_renderers.py b/api/tests/subsonic/test_renderers.py new file mode 100644 index 000000000..8e2ea3f85 --- /dev/null +++ b/api/tests/subsonic/test_renderers.py @@ -0,0 +1,44 @@ +import json +import xml.etree.ElementTree as ET + +from funkwhale_api.subsonic import renderers + + +def test_json_renderer(): + data = {'hello': 'world'} + expected = { + 'subsonic-response': { + 'status': 'ok', + 'version': '1.16.0', + 'hello': 'world' + } + } + renderer = renderers.SubsonicJSONRenderer() + assert json.loads(renderer.render(data)) == expected + + +def test_xml_renderer_dict_to_xml(): + payload = { + 'hello': 'world', + 'item': [ + {'this': 1}, + {'some': 'node'}, + ] + } + expected = """ +""" + result = renderers.dict_to_xml_tree('key', payload) + exp = ET.fromstring(expected) + assert ET.tostring(result) == ET.tostring(exp) + + +def test_xml_renderer(): + payload = { + 'hello': 'world', + } + expected = b'\n' + + renderer = renderers.SubsonicXMLRenderer() + rendered = renderer.render(payload) + + assert rendered == expected diff --git a/api/tests/subsonic/test_serializers.py b/api/tests/subsonic/test_serializers.py new file mode 100644 index 000000000..3a9de7c9f --- /dev/null +++ b/api/tests/subsonic/test_serializers.py @@ -0,0 +1,109 @@ +from funkwhale_api.subsonic import serializers + + +def test_get_artists_serializer(factories): + artist1 = factories['music.Artist'](name='eliot') + artist2 = factories['music.Artist'](name='Ellena') + artist3 = factories['music.Artist'](name='Rilay') + + factories['music.Album'].create_batch(size=3, artist=artist1) + factories['music.Album'].create_batch(size=2, artist=artist2) + + expected = { + 'ignoredArticles': '', + 'index': [ + { + 'name': 'E', + 'artist': [ + { + 'id': artist1.pk, + 'name': artist1.name, + 'albumCount': 3, + }, + { + 'id': artist2.pk, + 'name': artist2.name, + 'albumCount': 2, + }, + ] + }, + { + 'name': 'R', + 'artist': [ + { + 'id': artist3.pk, + 'name': artist3.name, + 'albumCount': 0, + }, + ] + }, + ] + } + + queryset = artist1.__class__.objects.filter(pk__in=[ + artist1.pk, artist2.pk, artist3.pk + ]) + + assert serializers.GetArtistsSerializer(queryset).data == expected + + +def test_get_artist_serializer(factories): + artist = factories['music.Artist']() + album = factories['music.Album'](artist=artist) + tracks = factories['music.Track'].create_batch(size=3, album=album) + + expected = { + 'id': artist.pk, + 'name': artist.name, + 'albumCount': 1, + 'album': [ + { + 'id': album.pk, + 'artistId': artist.pk, + 'name': album.title, + 'artist': artist.name, + 'songCount': len(tracks), + 'created': album.creation_date, + 'year': album.release_date.year, + } + ] + } + + assert serializers.GetArtistSerializer(artist).data == expected + + +def test_get_album_serializer(factories): + artist = factories['music.Artist']() + album = factories['music.Album'](artist=artist) + track = factories['music.Track'](album=album) + tf = factories['music.TrackFile'](track=track) + + expected = { + 'id': album.pk, + 'artistId': artist.pk, + 'name': album.title, + 'artist': artist.name, + 'songCount': 1, + 'created': album.creation_date, + 'year': album.release_date.year, + 'song': [ + { + 'id': track.pk, + 'isDir': False, + 'title': track.title, + 'album': album.title, + 'artist': artist.name, + 'track': track.position, + 'year': track.album.release_date.year, + 'contentType': tf.mimetype, + 'suffix': tf.extension, + 'duration': tf.duration, + 'created': track.creation_date, + 'albumId': album.pk, + 'artistId': artist.pk, + 'type': 'music', + } + ] + } + + assert serializers.GetAlbumSerializer(album).data == expected diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py new file mode 100644 index 000000000..daf4548b0 --- /dev/null +++ b/api/tests/subsonic/test_views.py @@ -0,0 +1,120 @@ +import json +import pytest + +from django.urls import reverse +from rest_framework.response import Response + +from funkwhale_api.music import models as music_models +from funkwhale_api.music import views as music_views +from funkwhale_api.subsonic import renderers +from funkwhale_api.subsonic import serializers + + +def render_json(data): + return json.loads(renderers.SubsonicJSONRenderer().render(data)) + + +def test_render_content_json(db, api_client): + url = reverse('api:subsonic-ping') + response = api_client.get(url, {'f': 'json'}) + + expected = { + 'status': 'ok', + 'version': '1.16.0' + } + assert response.status_code == 200 + assert json.loads(response.content) == render_json(expected) + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_exception_wrong_credentials(f, db, api_client): + url = reverse('api:subsonic-ping') + response = api_client.get(url, {'f': f, 'u': 'yolo'}) + + expected = { + 'status': 'failed', + 'error': { + 'code': 40, + 'message': 'Wrong username or password.' + } + } + assert response.status_code == 200 + assert response.data == expected + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_ping(f, db, api_client): + url = reverse('api:subsonic-ping') + response = api_client.get(url, {'f': f}) + + expected = { + 'status': 'ok', + 'version': '1.16.0', + } + assert response.status_code == 200 + assert response.data == expected + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_artists(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-artists') + assert url.endswith('getArtists') is True + artists = factories['music.Artist'].create_batch(size=10) + expected = { + 'artists': serializers.GetArtistsSerializer( + music_models.Artist.objects.all() + ).data + } + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == expected + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_artist(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-artist') + assert url.endswith('getArtist') is True + artist = factories['music.Artist']() + albums = factories['music.Album'].create_batch(size=3, artist=artist) + expected = { + 'artist': serializers.GetArtistSerializer(artist).data + } + response = logged_in_api_client.get(url, {'id': artist.pk}) + + assert response.status_code == 200 + assert response.data == expected + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_album(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-album') + assert url.endswith('getAlbum') is True + artist = factories['music.Artist']() + album = factories['music.Album'](artist=artist) + tracks = factories['music.Track'].create_batch(size=3, album=album) + expected = { + 'album': serializers.GetAlbumSerializer(album).data + } + response = logged_in_api_client.get(url, {'f': f, 'id': album.pk}) + + assert response.status_code == 200 + assert response.data == expected + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_stream(f, db, logged_in_api_client, factories, mocker): + url = reverse('api:subsonic-stream') + mocked_serve = mocker.spy( + music_views, 'handle_serve') + assert url.endswith('stream') is True + artist = factories['music.Artist']() + album = factories['music.Album'](artist=artist) + track = factories['music.Track'](album=album) + tf = factories['music.TrackFile'](track=track) + response = logged_in_api_client.get(url, {'f': f, 'id': track.pk}) + + mocked_serve.assert_called_once_with( + track_file=tf + ) + assert response.status_code == 200 From 40cde0cd924006890a87ead623e68ec465bbf63c Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Tue, 8 May 2018 21:21:52 +0200 Subject: [PATCH 04/15] Queryset methods on artist/albums --- api/funkwhale_api/music/models.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index e6253eb92..b8697a4f9 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -76,6 +76,11 @@ class APIModelMixin(models.Model): self.musicbrainz_model, self.mbid) +class ArtistQuerySet(models.QuerySet): + def with_albums_count(self): + return self.annotate(_albums_count=models.Count('albums')) + + class Artist(APIModelMixin): name = models.CharField(max_length=255) @@ -89,6 +94,7 @@ class Artist(APIModelMixin): } } api = musicbrainz.api.artists + objects = ArtistQuerySet.as_manager() def __str__(self): return self.name @@ -129,6 +135,11 @@ def import_tracks(instance, cleaned_data, raw_data): track = importers.load(Track, track_cleaned_data, track_data, Track.import_hooks) +class AlbumQuerySet(models.QuerySet): + def with_tracks_count(self): + return self.annotate(_tracks_count=models.Count('tracks')) + + class Album(APIModelMixin): title = models.CharField(max_length=255) artist = models.ForeignKey( @@ -173,6 +184,7 @@ class Album(APIModelMixin): 'converter': import_artist, } } + objects = AlbumQuerySet.as_manager() def get_image(self): image_data = musicbrainz.api.images.get_front(str(self.mbid)) From e31099ef332474aefd226a265345fce7742f9492 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Tue, 8 May 2018 21:22:52 +0200 Subject: [PATCH 05/15] See #75 more subsonic api endpoints (star, unstar, search...) --- api/funkwhale_api/subsonic/filters.py | 23 +++ api/funkwhale_api/subsonic/serializers.py | 130 ++++++++++++----- api/funkwhale_api/subsonic/views.py | 169 +++++++++++++++++++++- api/tests/subsonic/test_serializers.py | 32 +++- api/tests/subsonic/test_views.py | 122 ++++++++++++++++ 5 files changed, 433 insertions(+), 43 deletions(-) create mode 100644 api/funkwhale_api/subsonic/filters.py diff --git a/api/funkwhale_api/subsonic/filters.py b/api/funkwhale_api/subsonic/filters.py new file mode 100644 index 000000000..b7b639fac --- /dev/null +++ b/api/funkwhale_api/subsonic/filters.py @@ -0,0 +1,23 @@ +from django_filters import rest_framework as filters + +from funkwhale_api.music import models as music_models + + +class AlbumList2FilterSet(filters.FilterSet): + type = filters.CharFilter(name='_', method='filter_type') + + class Meta: + model = music_models.Album + fields = ['type'] + + def filter_type(self, queryset, name, value): + ORDERING = { + 'random': '?', + 'newest': '-creation_date', + 'alphabeticalByArtist': 'artist__name', + 'alphabeticalByName': 'title', + } + if value not in ORDERING: + return queryset + + return queryset.order_by(ORDERING[value]) diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py index 034343be0..59fdb9308 100644 --- a/api/funkwhale_api/subsonic/serializers.py +++ b/api/funkwhale_api/subsonic/serializers.py @@ -4,6 +4,16 @@ from django.db.models import functions, Count from rest_framework import serializers +from funkwhale_api.music import models as music_models + + +def get_artist_data(artist_values): + return { + 'id': artist_values['id'], + 'name': artist_values['name'], + 'albumCount': artist_values['_albums_count'] + } + class GetArtistsSerializer(serializers.Serializer): def to_representation(self, queryset): @@ -11,7 +21,7 @@ class GetArtistsSerializer(serializers.Serializer): 'ignoredArticles': '', 'index': [] } - queryset = queryset.annotate(_albums_count=Count('albums')) + queryset = queryset.with_albums_count() queryset = queryset.order_by(functions.Lower('name')) values = queryset.values('id', '_albums_count', 'name') @@ -23,11 +33,7 @@ class GetArtistsSerializer(serializers.Serializer): letter_data = { 'name': letter, 'artist': [ - { - 'id': v['id'], - 'name': v['name'], - 'albumCount': v['_albums_count'] - } + get_artist_data(v) for v in artists ] } @@ -59,42 +65,88 @@ class GetArtistSerializer(serializers.Serializer): return payload +def get_track_data(album, track, tf): + data = { + 'id': track.pk, + 'isDir': 'false', + 'title': track.title, + 'album': album.title, + 'artist': album.artist.name, + 'track': track.position, + 'contentType': tf.mimetype, + 'suffix': tf.extension or '', + 'duration': tf.duration or 0, + 'created': track.creation_date, + 'albumId': album.pk, + 'artistId': album.artist.pk, + 'type': 'music', + } + if album.release_date: + data['year'] = album.release_date.year + return data + + +def get_album2_data(album): + payload = { + 'id': album.id, + 'artistId': album.artist.id, + 'name': album.title, + 'artist': album.artist.name, + 'created': album.creation_date, + } + try: + payload['songCount'] = album._tracks_count + except AttributeError: + payload['songCount'] = len(album.tracks.prefetch_related('files')) + return payload + + +def get_song_list_data(tracks): + songs = [] + for track in tracks: + try: + tf = [tf for tf in track.files.all()][0] + except IndexError: + continue + track_data = get_track_data(track.album, track, tf) + songs.append(track_data) + return songs + + class GetAlbumSerializer(serializers.Serializer): def to_representation(self, album): - tracks = album.tracks.prefetch_related('files') - payload = { - 'id': album.id, - 'artistId': album.artist.id, - 'name': album.title, - 'artist': album.artist.name, - 'created': album.creation_date, - 'songCount': len(tracks), - 'song': [], - } + tracks = album.tracks.prefetch_related('files').select_related('album') + payload = get_album2_data(album) if album.release_date: payload['year'] = album.release_date.year - for track in tracks: - try: - tf = [tf for tf in track.files.all()][0] - except IndexError: - continue - track_data = { - 'id': track.pk, - 'isDir': False, - 'title': track.title, - 'album': album.title, - 'artist': album.artist.name, - 'track': track.position, - 'contentType': tf.mimetype, - 'suffix': tf.extension, - 'duration': tf.duration, - 'created': track.creation_date, - 'albumId': album.pk, - 'artistId': album.artist.pk, - 'type': 'music', - } - if album.release_date: - track_data['year'] = album.release_date.year - payload['song'].append(track_data) + payload['song'] = get_song_list_data(tracks) return payload + + +def get_starred_tracks_data(favorites): + by_track_id = { + f.track_id: f + for f in favorites + } + tracks = music_models.Track.objects.filter( + pk__in=by_track_id.keys() + ).select_related('album__artist').prefetch_related('files') + tracks = tracks.order_by('-creation_date') + data = [] + for t in tracks: + try: + tf = [tf for tf in t.files.all()][0] + except IndexError: + continue + td = get_track_data(t.album, t, tf) + td['starred'] = by_track_id[t.pk].creation_date + data.append(td) + return data + + +def get_album_list2_data(albums): + return [ + get_album2_data(a) + for a in albums + ] diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index 98fea7598..7bb961795 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -1,3 +1,7 @@ +import datetime + +from django.utils import timezone + from rest_framework import exceptions from rest_framework import permissions as rest_permissions from rest_framework import response @@ -5,10 +9,13 @@ from rest_framework import viewsets from rest_framework.decorators import list_route from rest_framework.serializers import ValidationError +from funkwhale_api.favorites.models import TrackFavorite from funkwhale_api.music import models as music_models +from funkwhale_api.music import utils from funkwhale_api.music import views as music_views from . import authentication +from . import filters from . import negotiation from . import serializers @@ -83,6 +90,24 @@ class SubsonicViewSet(viewsets.GenericViewSet): } return response.Response(data, status=200) + @list_route( + methods=['get', 'post'], + url_name='get_license', + permissions_classes=[], + url_path='getLicense') + def get_license(self, request, *args, **kwargs): + now = timezone.now() + data = { + 'status': 'ok', + 'version': '1.16.0', + 'license': { + 'valid': 'true', + 'email': 'valid@valid.license', + 'licenseExpires': now + datetime.timedelta(days=365) + } + } + return response.Response(data, status=200) + @list_route( methods=['get', 'post'], url_name='get_artists', @@ -110,6 +135,19 @@ class SubsonicViewSet(viewsets.GenericViewSet): return response.Response(payload, status=200) + @list_route( + methods=['get', 'post'], + url_name='get_artist_info2', + url_path='getArtistInfo2') + @find_object(music_models.Artist.objects.all()) + def get_artist_info2(self, request, *args, **kwargs): + artist = kwargs.pop('obj') + payload = { + 'artist-info2': {} + } + + return response.Response(payload, status=200) + @list_route( methods=['get', 'post'], url_name='get_album', @@ -139,5 +177,134 @@ class SubsonicViewSet(viewsets.GenericViewSet): ) track_file = queryset.first() if not track_file: - return Response(status=404) + return response.Response(status=404) return music_views.handle_serve(track_file) + + @list_route( + methods=['get', 'post'], + url_name='star', + url_path='star') + @find_object( + music_models.Track.objects.all()) + def star(self, request, *args, **kwargs): + track = kwargs.pop('obj') + TrackFavorite.add(user=request.user, track=track) + return response.Response({'status': 'ok'}) + + @list_route( + methods=['get', 'post'], + url_name='unstar', + url_path='unstar') + @find_object( + music_models.Track.objects.all()) + def unstar(self, request, *args, **kwargs): + track = kwargs.pop('obj') + request.user.track_favorites.filter(track=track).delete() + return response.Response({'status': 'ok'}) + + @list_route( + methods=['get', 'post'], + url_name='get_starred2', + url_path='getStarred2') + def get_starred2(self, request, *args, **kwargs): + favorites = request.user.track_favorites.all() + data = { + 'song': serializers.get_starred_tracks_data(favorites) + } + return response.Response(data) + + @list_route( + methods=['get', 'post'], + url_name='get_album_list2', + url_path='getAlbumList2') + def get_album_list2(self, request, *args, **kwargs): + queryset = music_models.Album.objects.with_tracks_count() + data = request.GET or request.POST + filterset = filters.AlbumList2FilterSet(data, queryset=queryset) + queryset = filterset.qs + try: + offset = int(data['offset']) + except (TypeError, KeyError, ValueError): + offset = 0 + + try: + size = int(data['size']) + except (TypeError, KeyError, ValueError): + size = 50 + + size = min(size, 500) + queryset = queryset[offset:size] + data = { + 'albumList2': { + 'album': serializers.get_album_list2_data(queryset) + } + } + return response.Response(data) + + + @list_route( + methods=['get', 'post'], + url_name='search3', + url_path='search3') + def search3(self, request, *args, **kwargs): + data = request.GET or request.POST + query = str(data.get('query', '')).replace('*', '') + conf = [ + { + 'subsonic': 'artist', + 'search_fields': ['name'], + 'queryset': ( + music_models.Artist.objects + .with_albums_count() + .values('id', '_albums_count', 'name') + ), + 'serializer': lambda qs: [ + serializers.get_artist_data(a) for a in qs + ] + }, + { + 'subsonic': 'album', + 'search_fields': ['title'], + 'queryset': ( + music_models.Album.objects + .with_tracks_count() + .select_related('artist') + ), + 'serializer': serializers.get_album_list2_data, + }, + { + 'subsonic': 'song', + 'search_fields': ['title'], + 'queryset': ( + music_models.Track.objects + .prefetch_related('files') + .select_related('album__artist') + ), + 'serializer': serializers.get_song_list_data, + }, + ] + payload = { + 'searchResult3': {} + } + for c in conf: + offsetKey = '{}Offset'.format(c['subsonic']) + countKey = '{}Count'.format(c['subsonic']) + try: + offset = int(data[offsetKey]) + except (TypeError, KeyError, ValueError): + offset = 0 + + try: + size = int(data[countKey]) + except (TypeError, KeyError, ValueError): + size = 20 + + size = min(size, 100) + queryset = c['queryset'] + if query: + queryset = c['queryset'].filter( + utils.get_query(query, c['search_fields']) + ) + queryset = queryset[offset:size] + payload['searchResult3'][c['subsonic']] = c['serializer'](queryset) + return response.Response(payload) diff --git a/api/tests/subsonic/test_serializers.py b/api/tests/subsonic/test_serializers.py index 3a9de7c9f..64ecc3b34 100644 --- a/api/tests/subsonic/test_serializers.py +++ b/api/tests/subsonic/test_serializers.py @@ -1,3 +1,4 @@ +from funkwhale_api.music import models as music_models from funkwhale_api.subsonic import serializers @@ -89,15 +90,15 @@ def test_get_album_serializer(factories): 'song': [ { 'id': track.pk, - 'isDir': False, + 'isDir': 'false', 'title': track.title, 'album': album.title, 'artist': artist.name, 'track': track.position, 'year': track.album.release_date.year, 'contentType': tf.mimetype, - 'suffix': tf.extension, - 'duration': tf.duration, + 'suffix': tf.extension or '', + 'duration': tf.duration or 0, 'created': track.creation_date, 'albumId': album.pk, 'artistId': artist.pk, @@ -107,3 +108,28 @@ def test_get_album_serializer(factories): } assert serializers.GetAlbumSerializer(album).data == expected + + +def test_starred_tracks2_serializer(factories): + artist = factories['music.Artist']() + album = factories['music.Album'](artist=artist) + track = factories['music.Track'](album=album) + tf = factories['music.TrackFile'](track=track) + favorite = factories['favorites.TrackFavorite'](track=track) + expected = [serializers.get_track_data(album, track, tf)] + expected[0]['starred'] = favorite.creation_date + data = serializers.get_starred_tracks_data([favorite]) + assert data == expected + + +def test_get_album_list2_serializer(factories): + album1 = factories['music.Album']() + album2 = factories['music.Album']() + + qs = music_models.Album.objects.with_tracks_count().order_by('pk') + expected = [ + serializers.get_album2_data(album1), + serializers.get_album2_data(album2), + ] + data = serializers.get_album_list2_data(qs) + assert data == expected diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py index daf4548b0..9fd9bfe38 100644 --- a/api/tests/subsonic/test_views.py +++ b/api/tests/subsonic/test_views.py @@ -1,7 +1,10 @@ +import datetime import json import pytest +from django.utils import timezone from django.urls import reverse + from rest_framework.response import Response from funkwhale_api.music import models as music_models @@ -42,6 +45,26 @@ def test_exception_wrong_credentials(f, db, api_client): assert response.data == expected +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_license(f, db, logged_in_api_client, mocker): + url = reverse('api:subsonic-get-license') + assert url.endswith('getLicense') is True + now = timezone.now() + mocker.patch('django.utils.timezone.now', return_value=now) + response = logged_in_api_client.get(url, {'f': f}) + expected = { + 'status': 'ok', + 'version': '1.16.0', + 'license': { + 'valid': 'true', + 'email': 'valid@valid.license', + 'licenseExpires': now + datetime.timedelta(days=365) + } + } + assert response.status_code == 200 + assert response.data == expected + + @pytest.mark.parametrize('f', ['xml', 'json']) def test_ping(f, db, api_client): url = reverse('api:subsonic-ping') @@ -86,6 +109,21 @@ def test_get_artist(f, db, logged_in_api_client, factories): assert response.data == expected +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_artist_info2(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-artist-info2') + assert url.endswith('getArtistInfo2') is True + artist = factories['music.Artist']() + + expected = { + 'artist-info2': {} + } + response = logged_in_api_client.get(url, {'id': artist.pk}) + + assert response.status_code == 200 + assert response.data == expected + + @pytest.mark.parametrize('f', ['xml', 'json']) def test_get_album(f, db, logged_in_api_client, factories): url = reverse('api:subsonic-get-album') @@ -118,3 +156,87 @@ def test_stream(f, db, logged_in_api_client, factories, mocker): track_file=tf ) assert response.status_code == 200 + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_star(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-star') + assert url.endswith('star') is True + track = factories['music.Track']() + response = logged_in_api_client.get(url, {'f': f, 'id': track.pk}) + + assert response.status_code == 200 + assert response.data == {'status': 'ok'} + + favorite = logged_in_api_client.user.track_favorites.latest('id') + assert favorite.track == track + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_unstar(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-unstar') + assert url.endswith('unstar') is True + track = factories['music.Track']() + favorite = factories['favorites.TrackFavorite']( + track=track, user=logged_in_api_client.user) + response = logged_in_api_client.get(url, {'f': f, 'id': track.pk}) + + assert response.status_code == 200 + assert response.data == {'status': 'ok'} + assert logged_in_api_client.user.track_favorites.count() == 0 + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_starred2(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-starred2') + assert url.endswith('getStarred2') is True + track = factories['music.Track']() + favorite = factories['favorites.TrackFavorite']( + track=track, user=logged_in_api_client.user) + response = logged_in_api_client.get(url, {'f': f, 'id': track.pk}) + + assert response.status_code == 200 + assert response.data == { + 'song': serializers.get_starred_tracks_data([favorite]) + } + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_album_list2(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-album-list2') + assert url.endswith('getAlbumList2') is True + album1 = factories['music.Album']() + album2 = factories['music.Album']() + response = logged_in_api_client.get(url, {'f': f, 'type': 'newest'}) + + assert response.status_code == 200 + assert response.data == { + 'albumList2': { + 'album': serializers.get_album_list2_data([album2, album1]) + } + } + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_search3(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-search3') + assert url.endswith('search3') is True + artist = factories['music.Artist'](name='testvalue') + factories['music.Artist'](name='nope') + album = factories['music.Album'](title='testvalue') + factories['music.Album'](title='nope') + track = factories['music.Track'](title='testvalue') + factories['music.Track'](title='nope') + + response = logged_in_api_client.get(url, {'f': f, 'query': 'testval'}) + + artist_qs = music_models.Artist.objects.with_albums_count().filter( + pk=artist.pk).values('_albums_count', 'id', 'name') + assert response.status_code == 200 + assert response.data == { + 'searchResult3': { + 'artist': [serializers.get_artist_data(a) for a in artist_qs], + 'album': serializers.get_album_list2_data([album]), + 'song': serializers.get_song_list_data([track]), + } + } From 7e9320fc1c8e97f319d4b20e6a4ada7441b52338 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Tue, 8 May 2018 23:06:29 +0200 Subject: [PATCH 06/15] Queryset methods for playlists --- api/funkwhale_api/playlists/models.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py index a208a5fd0..f5132e12d 100644 --- a/api/funkwhale_api/playlists/models.py +++ b/api/funkwhale_api/playlists/models.py @@ -9,6 +9,12 @@ from funkwhale_api.common import fields from funkwhale_api.common import preferences +class PlaylistQuerySet(models.QuerySet): + def with_tracks_count(self): + return self.annotate( + _tracks_count=models.Count('playlist_tracks')) + + class Playlist(models.Model): name = models.CharField(max_length=50) user = models.ForeignKey( @@ -18,6 +24,8 @@ class Playlist(models.Model): auto_now=True) privacy_level = fields.get_privacy_field() + objects = PlaylistQuerySet.as_manager() + def __str__(self): return self.name From 1674ad919f6ec9f63ca7a70e66aa5d9da7f76881 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Tue, 8 May 2018 23:06:47 +0200 Subject: [PATCH 07/15] See #75: implemented subsonic playlist API endpoints --- api/funkwhale_api/subsonic/serializers.py | 28 +++++ api/funkwhale_api/subsonic/views.py | 124 +++++++++++++++++++++- api/tests/subsonic/test_serializers.py | 40 +++++++ api/tests/subsonic/test_views.py | 91 ++++++++++++++++ 4 files changed, 279 insertions(+), 4 deletions(-) diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py index 59fdb9308..d098279b2 100644 --- a/api/funkwhale_api/subsonic/serializers.py +++ b/api/funkwhale_api/subsonic/serializers.py @@ -150,3 +150,31 @@ def get_album_list2_data(albums): get_album2_data(a) for a in albums ] + + +def get_playlist_data(playlist): + return { + 'id': playlist.pk, + 'name': playlist.name, + 'owner': playlist.user.username, + 'public': 'false', + 'songCount': playlist._tracks_count, + 'duration': 0, + 'created': playlist.creation_date, + } + + +def get_playlist_detail_data(playlist): + data = get_playlist_data(playlist) + qs = playlist.playlist_tracks.select_related( + 'track__album__artist' + ).prefetch_related('track__files').order_by('index') + data['entry'] = [] + for plt in qs: + try: + tf = [tf for tf in plt.track.files.all()][0] + except IndexError: + continue + td = get_track_data(plt.track.album, plt.track, tf) + data['entry'].append(td) + return data diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index 7bb961795..3836019d4 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -13,6 +13,7 @@ from funkwhale_api.favorites.models import TrackFavorite from funkwhale_api.music import models as music_models from funkwhale_api.music import utils from funkwhale_api.music import views as music_views +from funkwhale_api.playlists import models as playlists_models from . import authentication from . import filters @@ -38,13 +39,16 @@ def find_object(queryset, model_field='pk', field='id', cast=int): 'code': 0, 'message': 'For input string "{}"'.format(raw_value) }) + qs = queryset + if hasattr(qs, '__call__'): + qs = qs(request) try: - obj = queryset.get(**{model_field: value}) - except queryset.model.DoesNotExist: + obj = qs.get(**{model_field: value}) + except qs.model.DoesNotExist: return response.Response({ 'code': 70, 'message': '{} not found'.format( - queryset.model.__class__.__name__) + qs.model.__class__.__name__) }) kwargs['obj'] = obj return func(self, request, *args, **kwargs) @@ -241,7 +245,6 @@ class SubsonicViewSet(viewsets.GenericViewSet): } return response.Response(data) - @list_route( methods=['get', 'post'], url_name='search3', @@ -308,3 +311,116 @@ class SubsonicViewSet(viewsets.GenericViewSet): queryset = queryset[offset:size] payload['searchResult3'][c['subsonic']] = c['serializer'](queryset) return response.Response(payload) + + @list_route( + methods=['get', 'post'], + url_name='get_playlists', + url_path='getPlaylists') + def get_playlists(self, request, *args, **kwargs): + playlists = request.user.playlists.with_tracks_count().select_related( + 'user' + ) + data = { + 'playlists': { + 'playlist': [ + serializers.get_playlist_data(p) for p in playlists] + } + } + return response.Response(data) + + @list_route( + methods=['get', 'post'], + url_name='get_playlist', + url_path='getPlaylist') + @find_object( + playlists_models.Playlist.objects.with_tracks_count()) + def get_playlist(self, request, *args, **kwargs): + playlist = kwargs.pop('obj') + data = { + 'playlist': serializers.get_playlist_detail_data(playlist) + } + return response.Response(data) + + @list_route( + methods=['get', 'post'], + url_name='update_playlist', + url_path='updatePlaylist') + @find_object( + lambda request: request.user.playlists.all(), + field='playlistId') + def update_playlist(self, request, *args, **kwargs): + playlist = kwargs.pop('obj') + data = request.GET or request.POST + new_name = data.get('name', '') + if new_name: + playlist.name = new_name + playlist.save(update_fields=['name', 'modification_date']) + try: + to_remove = int(data['songIndexToRemove']) + plt = playlist.playlist_tracks.get(index=to_remove) + except (TypeError, ValueError, KeyError): + pass + except playlists_models.PlaylistTrack.DoesNotExist: + pass + else: + plt.delete(update_indexes=True) + + try: + to_add = int(data['songIdToAdd']) + track = music_models.Track.objects.get(pk=to_add) + except (TypeError, ValueError, KeyError): + pass + except music_models.Track.DoesNotExist: + pass + else: + playlist.insert_many([track]) + data = { + 'status': 'ok' + } + return response.Response(data) + + @list_route( + methods=['get', 'post'], + url_name='delete_playlist', + url_path='deletePlaylist') + @find_object( + lambda request: request.user.playlists.all()) + def delete_playlist(self, request, *args, **kwargs): + playlist = kwargs.pop('obj') + playlist.delete() + data = { + 'status': 'ok' + } + return response.Response(data) + + @list_route( + methods=['get', 'post'], + url_name='create_playlist', + url_path='createPlaylist') + def create_playlist(self, request, *args, **kwargs): + data = request.GET or request.POST + name = data.get('name', '') + if not name: + return response.Response({ + 'code': 10, + 'message': 'Playlist ID or name must be specified.' + }, data) + + playlist = request.user.playlists.create( + name=name + ) + try: + to_add = int(data['songId']) + track = music_models.Track.objects.get(pk=to_add) + except (TypeError, ValueError, KeyError): + pass + except music_models.Track.DoesNotExist: + pass + else: + playlist.insert_many([track]) + playlist = request.user.playlists.with_tracks_count().get( + pk=playlist.pk) + data = { + 'playlist': serializers.get_playlist_detail_data(playlist) + } + return response.Response(data) diff --git a/api/tests/subsonic/test_serializers.py b/api/tests/subsonic/test_serializers.py index 64ecc3b34..bb0e8407b 100644 --- a/api/tests/subsonic/test_serializers.py +++ b/api/tests/subsonic/test_serializers.py @@ -133,3 +133,43 @@ def test_get_album_list2_serializer(factories): ] data = serializers.get_album_list2_data(qs) assert data == expected + + +def test_playlist_serializer(factories): + plt = factories['playlists.PlaylistTrack']() + playlist = plt.playlist + qs = music_models.Album.objects.with_tracks_count().order_by('pk') + expected = { + 'id': playlist.pk, + 'name': playlist.name, + 'owner': playlist.user.username, + 'public': 'false', + 'songCount': 1, + 'duration': 0, + 'created': playlist.creation_date, + } + qs = playlist.__class__.objects.with_tracks_count() + data = serializers.get_playlist_data(qs.first()) + assert data == expected + + +def test_playlist_detail_serializer(factories): + plt = factories['playlists.PlaylistTrack']() + tf = factories['music.TrackFile'](track=plt.track) + playlist = plt.playlist + qs = music_models.Album.objects.with_tracks_count().order_by('pk') + expected = { + 'id': playlist.pk, + 'name': playlist.name, + 'owner': playlist.user.username, + 'public': 'false', + 'songCount': 1, + 'duration': 0, + 'created': playlist.creation_date, + 'entry': [ + serializers.get_track_data(plt.track.album, plt.track, tf) + ] + } + qs = playlist.__class__.objects.with_tracks_count() + data = serializers.get_playlist_detail_data(qs.first()) + assert data == expected diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py index 9fd9bfe38..93aa72855 100644 --- a/api/tests/subsonic/test_views.py +++ b/api/tests/subsonic/test_views.py @@ -240,3 +240,94 @@ def test_search3(f, db, logged_in_api_client, factories): 'song': serializers.get_song_list_data([track]), } } + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_playlists(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-playlists') + assert url.endswith('getPlaylists') is True + playlist = factories['playlists.Playlist']( + user=logged_in_api_client.user + ) + response = logged_in_api_client.get(url, {'f': f}) + + qs = playlist.__class__.objects.with_tracks_count() + assert response.status_code == 200 + assert response.data == { + 'playlists': { + 'playlist': [serializers.get_playlist_data(qs.first())], + } + } + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_playlist(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-playlist') + assert url.endswith('getPlaylist') is True + playlist = factories['playlists.Playlist']( + user=logged_in_api_client.user + ) + response = logged_in_api_client.get(url, {'f': f, 'id': playlist.pk}) + + qs = playlist.__class__.objects.with_tracks_count() + assert response.status_code == 200 + assert response.data == { + 'playlist': serializers.get_playlist_detail_data(qs.first()) + } + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_update_playlist(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-update-playlist') + assert url.endswith('updatePlaylist') is True + playlist = factories['playlists.Playlist']( + user=logged_in_api_client.user + ) + plt = factories['playlists.PlaylistTrack']( + index=0, playlist=playlist) + new_track = factories['music.Track']() + response = logged_in_api_client.get( + url, { + 'f': f, + 'name': 'new_name', + 'playlistId': playlist.pk, + 'songIdToAdd': new_track.pk, + 'songIndexToRemove': 0}) + playlist.refresh_from_db() + assert response.status_code == 200 + assert playlist.name == 'new_name' + assert playlist.playlist_tracks.count() == 1 + assert playlist.playlist_tracks.first().track_id == new_track.pk + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_delete_playlist(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-delete-playlist') + assert url.endswith('deletePlaylist') is True + playlist = factories['playlists.Playlist']( + user=logged_in_api_client.user + ) + response = logged_in_api_client.get( + url, {'f': f, 'id': playlist.pk}) + assert response.status_code == 200 + with pytest.raises(playlist.__class__.DoesNotExist): + playlist.refresh_from_db() + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_create_playlist(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-create-playlist') + assert url.endswith('createPlaylist') is True + track = factories['music.Track']() + response = logged_in_api_client.get( + url, {'f': f, 'name': 'hello', 'songId': track.pk}) + assert response.status_code == 200 + playlist = logged_in_api_client.user.playlists.latest('id') + plt = playlist.playlist_tracks.latest('id') + assert playlist.name == 'hello' + assert plt.index == 0 + assert plt.track == track + qs = playlist.__class__.objects.with_tracks_count() + assert response.data == { + 'playlist': serializers.get_playlist_detail_data(qs.first()) + } From 7c85d1ca81cc6512add5895264956f05af347e96 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 9 May 2018 20:31:47 +0200 Subject: [PATCH 08/15] See #75: can now add multiple tracks to a playlist on creation and update --- api/funkwhale_api/subsonic/views.py | 54 +++++++++++++++++++---------- api/tests/subsonic/test_views.py | 12 ++++--- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index 3836019d4..f428e81d9 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -365,15 +365,24 @@ class SubsonicViewSet(viewsets.GenericViewSet): else: plt.delete(update_indexes=True) - try: - to_add = int(data['songIdToAdd']) - track = music_models.Track.objects.get(pk=to_add) - except (TypeError, ValueError, KeyError): - pass - except music_models.Track.DoesNotExist: - pass - else: - playlist.insert_many([track]) + ids = [] + for i in data.getlist('songIdToAdd'): + try: + ids.append(int(i)) + except (TypeError, ValueError): + pass + if ids: + tracks = music_models.Track.objects.filter(pk__in=ids) + by_id = {t.id: t for t in tracks} + sorted_tracks = [] + for i in ids: + try: + sorted_tracks.append(by_id[i]) + except KeyError: + pass + if sorted_tracks: + playlist.insert_many(sorted_tracks) + data = { 'status': 'ok' } @@ -409,15 +418,24 @@ class SubsonicViewSet(viewsets.GenericViewSet): playlist = request.user.playlists.create( name=name ) - try: - to_add = int(data['songId']) - track = music_models.Track.objects.get(pk=to_add) - except (TypeError, ValueError, KeyError): - pass - except music_models.Track.DoesNotExist: - pass - else: - playlist.insert_many([track]) + ids = [] + for i in data.getlist('songId'): + try: + ids.append(int(i)) + except (TypeError, ValueError): + pass + + if ids: + tracks = music_models.Track.objects.filter(pk__in=ids) + by_id = {t.id: t for t in tracks} + sorted_tracks = [] + for i in ids: + try: + sorted_tracks.append(by_id[i]) + except KeyError: + pass + if sorted_tracks: + playlist.insert_many(sorted_tracks) playlist = request.user.playlists.with_tracks_count().get( pk=playlist.pk) data = { diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py index 93aa72855..781c05331 100644 --- a/api/tests/subsonic/test_views.py +++ b/api/tests/subsonic/test_views.py @@ -318,15 +318,17 @@ def test_delete_playlist(f, db, logged_in_api_client, factories): def test_create_playlist(f, db, logged_in_api_client, factories): url = reverse('api:subsonic-create-playlist') assert url.endswith('createPlaylist') is True - track = factories['music.Track']() + track1 = factories['music.Track']() + track2 = factories['music.Track']() response = logged_in_api_client.get( - url, {'f': f, 'name': 'hello', 'songId': track.pk}) + url, {'f': f, 'name': 'hello', 'songId': [track1.pk, track2.pk]}) assert response.status_code == 200 playlist = logged_in_api_client.user.playlists.latest('id') - plt = playlist.playlist_tracks.latest('id') + assert playlist.playlist_tracks.count() == 2 + for i, t in enumerate([track1, track2]): + plt = playlist.playlist_tracks.get(track=t) + assert plt.index == i assert playlist.name == 'hello' - assert plt.index == 0 - assert plt.track == track qs = playlist.__class__.objects.with_tracks_count() assert response.data == { 'playlist': serializers.get_playlist_detail_data(qs.first()) From 67dc414c8a7ca8e796087362c099b9b5493775fd Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 9 May 2018 20:32:17 +0200 Subject: [PATCH 09/15] See #75: music direcory and indexes endpoints --- api/funkwhale_api/subsonic/serializers.py | 37 ++++++++++++++- api/funkwhale_api/subsonic/views.py | 45 ++++++++++++++++++- api/tests/subsonic/test_serializers.py | 32 +++++++++++++ api/tests/subsonic/test_views.py | 55 ++++++++++++++++++++++- 4 files changed, 165 insertions(+), 4 deletions(-) diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py index d098279b2..5bc452886 100644 --- a/api/funkwhale_api/subsonic/serializers.py +++ b/api/funkwhale_api/subsonic/serializers.py @@ -72,7 +72,7 @@ def get_track_data(album, track, tf): 'title': track.title, 'album': album.title, 'artist': album.artist.name, - 'track': track.position, + 'track': track.position or 1, 'contentType': tf.mimetype, 'suffix': tf.extension or '', 'duration': tf.duration or 0, @@ -178,3 +178,38 @@ def get_playlist_detail_data(playlist): td = get_track_data(plt.track.album, plt.track, tf) data['entry'].append(td) return data + + +def get_music_directory_data(artist): + tracks = artist.tracks.select_related('album').prefetch_related('files') + data = { + 'id': artist.pk, + 'parent': 1, + 'name': artist.name, + 'child': [] + } + for track in tracks: + try: + tf = [tf for tf in track.files.all()][0] + except IndexError: + continue + album = track.album + td = { + 'id': track.pk, + 'isDir': 'false', + 'title': track.title, + 'album': album.title, + 'artist': artist.name, + 'track': track.position or 1, + 'year': track.album.release_date.year if track.album.release_date else 0, + 'contentType': tf.mimetype, + 'suffix': tf.extension or '', + 'duration': tf.duration or 0, + 'created': track.creation_date, + 'albumId': album.pk, + 'artistId': artist.pk, + 'parent': artist.id, + 'type': 'music', + } + data['child'].append(td) + return data diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index f428e81d9..252afe81a 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -125,6 +125,19 @@ class SubsonicViewSet(viewsets.GenericViewSet): return response.Response(payload, status=200) + @list_route( + methods=['get', 'post'], + url_name='get_indexes', + url_path='getIndexes') + def get_indexes(self, request, *args, **kwargs): + artists = music_models.Artist.objects.all() + data = serializers.GetArtistsSerializer(artists).data + payload = { + 'indexes': data + } + + return response.Response(payload, status=200) + @list_route( methods=['get', 'post'], url_name='get_artist', @@ -213,7 +226,22 @@ class SubsonicViewSet(viewsets.GenericViewSet): def get_starred2(self, request, *args, **kwargs): favorites = request.user.track_favorites.all() data = { - 'song': serializers.get_starred_tracks_data(favorites) + 'starred2': { + 'song': serializers.get_starred_tracks_data(favorites) + } + } + return response.Response(data) + + @list_route( + methods=['get', 'post'], + url_name='get_starred', + url_path='getStarred') + def get_starred(self, request, *args, **kwargs): + favorites = request.user.track_favorites.all() + data = { + 'starred': { + 'song': serializers.get_starred_tracks_data(favorites) + } } return response.Response(data) @@ -442,3 +470,18 @@ class SubsonicViewSet(viewsets.GenericViewSet): 'playlist': serializers.get_playlist_detail_data(playlist) } return response.Response(data) + + @list_route( + methods=['get', 'post'], + url_name='get_music_folders', + url_path='getMusicFolders') + def get_music_folders(self, request, *args, **kwargs): + data = { + 'musicFolders': { + 'musicFolder': [{ + 'id': 1, + 'name': 'Music' + }] + } + } + return response.Response(data) diff --git a/api/tests/subsonic/test_serializers.py b/api/tests/subsonic/test_serializers.py index bb0e8407b..6da9dd12e 100644 --- a/api/tests/subsonic/test_serializers.py +++ b/api/tests/subsonic/test_serializers.py @@ -173,3 +173,35 @@ def test_playlist_detail_serializer(factories): qs = playlist.__class__.objects.with_tracks_count() data = serializers.get_playlist_detail_data(qs.first()) assert data == expected + + +def test_directory_serializer_artist(factories): + track = factories['music.Track']() + tf = factories['music.TrackFile'](track=track) + album = track.album + artist = track.artist + + expected = { + 'id': artist.pk, + 'parent': 1, + 'name': artist.name, + 'child': [{ + 'id': track.pk, + 'isDir': 'false', + 'title': track.title, + 'album': album.title, + 'artist': artist.name, + 'track': track.position, + 'year': track.album.release_date.year, + 'contentType': tf.mimetype, + 'suffix': tf.extension or '', + 'duration': tf.duration or 0, + 'created': track.creation_date, + 'albumId': album.pk, + 'artistId': artist.pk, + 'parent': artist.pk, + 'type': 'music', + }] + } + data = serializers.get_music_directory_data(artist) + assert data == expected diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py index 781c05331..b69be0d44 100644 --- a/api/tests/subsonic/test_views.py +++ b/api/tests/subsonic/test_views.py @@ -88,7 +88,7 @@ def test_get_artists(f, db, logged_in_api_client, factories): music_models.Artist.objects.all() ).data } - response = logged_in_api_client.get(url) + response = logged_in_api_client.get(url, {'f': f}) assert response.status_code == 200 assert response.data == expected @@ -197,7 +197,26 @@ def test_get_starred2(f, db, logged_in_api_client, factories): assert response.status_code == 200 assert response.data == { - 'song': serializers.get_starred_tracks_data([favorite]) + 'starred2': { + 'song': serializers.get_starred_tracks_data([favorite]) + } + } + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_starred(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-starred') + assert url.endswith('getStarred') is True + track = factories['music.Track']() + favorite = factories['favorites.TrackFavorite']( + track=track, user=logged_in_api_client.user) + response = logged_in_api_client.get(url, {'f': f, 'id': track.pk}) + + assert response.status_code == 200 + assert response.data == { + 'starred': { + 'song': serializers.get_starred_tracks_data([favorite]) + } } @@ -333,3 +352,35 @@ def test_create_playlist(f, db, logged_in_api_client, factories): assert response.data == { 'playlist': serializers.get_playlist_detail_data(qs.first()) } + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_music_folders(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-music-folders') + assert url.endswith('getMusicFolders') is True + response = logged_in_api_client.get(url, {'f': f}) + assert response.status_code == 200 + assert response.data == { + 'musicFolders': { + 'musicFolder': [{ + 'id': 1, + 'name': 'Music' + }] + } + } + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_indexes(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-indexes') + assert url.endswith('getIndexes') is True + artists = factories['music.Artist'].create_batch(size=10) + expected = { + 'indexes': serializers.GetArtistsSerializer( + music_models.Artist.objects.all() + ).data + } + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == expected From 632aca51533d5ff7dce8fd2028948a6f3bc69d43 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 9 May 2018 20:38:26 +0200 Subject: [PATCH 10/15] Dev setup for working with subsonic API --- docker/nginx/conf.dev | 4 ++++ front/config/index.js | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev index 38c3de6c7..ab6714e60 100644 --- a/docker/nginx/conf.dev +++ b/docker/nginx/conf.dev @@ -82,5 +82,9 @@ http { include /etc/nginx/funkwhale_proxy.conf; proxy_pass http://api:12081/; } + location /rest/ { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://api:12081/api/subsonic/rest/; + } } } diff --git a/front/config/index.js b/front/config/index.js index 669ce54f3..f4996f020 100644 --- a/front/config/index.js +++ b/front/config/index.js @@ -34,7 +34,7 @@ module.exports = { changeOrigin: true, ws: true, filter: function (pathname, req) { - let proxified = ['.well-known', 'staticfiles', 'media', 'federation', 'api'] + let proxified = ['rest', '.well-known', 'staticfiles', 'media', 'federation', 'api'] let matches = proxified.filter(e => { return pathname.match(`^/${e}`) }) From 759593626070117930e72df7b0ee12fcaef9733d Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 9 May 2018 22:13:04 +0200 Subject: [PATCH 11/15] See #75: added a preference do enable/disable the subsonic API on the instance level --- api/config/settings/common.py | 1 + .../subsonic/dynamic_preferences_registry.py | 22 +++++++++++++++++++ api/funkwhale_api/subsonic/views.py | 11 ++++++++++ front/src/store/instance.js | 5 +++++ 4 files changed, 39 insertions(+) create mode 100644 api/funkwhale_api/subsonic/dynamic_preferences_registry.py diff --git a/api/config/settings/common.py b/api/config/settings/common.py index f88aa5dd5..5fed9f25e 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -133,6 +133,7 @@ LOCAL_APPS = ( 'funkwhale_api.providers.audiofile', 'funkwhale_api.providers.youtube', 'funkwhale_api.providers.acoustid', + 'funkwhale_api.subsonic', ) # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps diff --git a/api/funkwhale_api/subsonic/dynamic_preferences_registry.py b/api/funkwhale_api/subsonic/dynamic_preferences_registry.py new file mode 100644 index 000000000..93482702f --- /dev/null +++ b/api/funkwhale_api/subsonic/dynamic_preferences_registry.py @@ -0,0 +1,22 @@ +from dynamic_preferences import types +from dynamic_preferences.registries import global_preferences_registry + +from funkwhale_api.common import preferences + +subsonic = types.Section('subsonic') + + +@global_preferences_registry.register +class APIAutenticationRequired(types.BooleanPreference): + section = subsonic + show_in_api = True + name = 'enabled' + default = True + verbose_name = 'Enabled Subsonic API' + help_text = ( + 'Funkwhale supports a subset of the Subsonic API, that makes ' + 'it compatible with existing clients such as DSub for Android ' + 'or Clementine for desktop. However, Subsonic protocol is less ' + 'than ideal in terms of security and you can disable this feature ' + 'completely using this flag.' + ) diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index 252afe81a..475e61aa7 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -4,11 +4,13 @@ from django.utils import timezone from rest_framework import exceptions from rest_framework import permissions as rest_permissions +from rest_framework import renderers from rest_framework import response from rest_framework import viewsets from rest_framework.decorators import list_route from rest_framework.serializers import ValidationError +from funkwhale_api.common import preferences from funkwhale_api.favorites.models import TrackFavorite from funkwhale_api.music import models as music_models from funkwhale_api.music import utils @@ -61,6 +63,15 @@ class SubsonicViewSet(viewsets.GenericViewSet): authentication_classes = [authentication.SubsonicAuthentication] permissions_classes = [rest_permissions.IsAuthenticated] + def dispatch(self, request, *args, **kwargs): + if not preferences.get('subsonic__enabled'): + r = response.Response({}, status=405) + r.accepted_renderer = renderers.JSONRenderer() + r.accepted_media_type = 'application/json' + r.renderer_context = {} + return r + return super().dispatch(request, *args, **kwargs) + def handle_exception(self, exc): # subsonic API sends 200 status code with custom error # codes in the payload diff --git a/front/src/store/instance.js b/front/src/store/instance.js index 245acaf03..e78e80489 100644 --- a/front/src/store/instance.js +++ b/front/src/store/instance.js @@ -24,6 +24,11 @@ export default { value: true } }, + subsonic: { + enabled: { + value: true + } + }, raven: { front_enabled: { value: false From 24cb1d95191d3bc83278ff30da37c2b4b2a35534 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 9 May 2018 22:18:33 +0200 Subject: [PATCH 12/15] See #75: user can now manage the Subsonic API token from their settings page --- api/funkwhale_api/users/views.py | 26 +++- api/tests/subsonic/test_views.py | 7 + api/tests/users/test_views.py | 71 +++++++++ front/src/components/auth/Settings.vue | 27 +++- .../src/components/auth/SubsonicTokenForm.vue | 137 ++++++++++++++++++ 5 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 front/src/components/auth/SubsonicTokenForm.vue diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py index 7c58363a3..0cc317889 100644 --- a/api/funkwhale_api/users/views.py +++ b/api/funkwhale_api/users/views.py @@ -1,11 +1,13 @@ from rest_framework.response import Response from rest_framework import mixins from rest_framework import viewsets -from rest_framework.decorators import list_route +from rest_framework.decorators import detail_route, list_route from rest_auth.registration.views import RegisterView as BaseRegisterView from allauth.account.adapter import get_adapter +from funkwhale_api.common import preferences + from . import models from . import serializers @@ -37,6 +39,28 @@ class UserViewSet( serializer = serializers.UserReadSerializer(request.user) return Response(serializer.data) + @detail_route( + methods=['get', 'post', 'delete'], url_path='subsonic-token') + def subsonic_token(self, request, *args, **kwargs): + if not self.request.user.username == kwargs.get('username'): + return Response(status=403) + if not preferences.get('subsonic__enabled'): + return Response(status=405) + if request.method.lower() == 'get': + return Response({ + 'subsonic_api_token': self.request.user.subsonic_api_token + }) + if request.method.lower() == 'delete': + self.request.user.subsonic_api_token = None + self.request.user.save(update_fields=['subsonic_api_token']) + return Response(status=204) + self.request.user.update_subsonic_api_token() + self.request.user.save(update_fields=['subsonic_api_token']) + data = { + 'subsonic_api_token': self.request.user.subsonic_api_token + } + return Response(data) + def update(self, request, *args, **kwargs): if not self.request.user.username == kwargs.get('username'): return Response(status=403) diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py index b69be0d44..bd445e070 100644 --- a/api/tests/subsonic/test_views.py +++ b/api/tests/subsonic/test_views.py @@ -45,6 +45,13 @@ def test_exception_wrong_credentials(f, db, api_client): assert response.data == expected +def test_disabled_subsonic(preferences, api_client): + preferences['subsonic__enabled'] = False + url = reverse('api:subsonic-ping') + response = api_client.get(url) + assert response.status_code == 405 + + @pytest.mark.parametrize('f', ['xml', 'json']) def test_get_license(f, db, logged_in_api_client, mocker): url = reverse('api:subsonic-get-license') diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py index 985a78c8a..fffc762fd 100644 --- a/api/tests/users/test_views.py +++ b/api/tests/users/test_views.py @@ -167,6 +167,77 @@ def test_user_can_patch_his_own_settings(logged_in_api_client): assert user.privacy_level == 'me' +def test_user_can_request_new_subsonic_token(logged_in_api_client): + user = logged_in_api_client.user + user.subsonic_api_token = 'test' + user.save() + + url = reverse( + 'api:v1:users:users-subsonic-token', + kwargs={'username': user.username}) + + response = logged_in_api_client.post(url) + + assert response.status_code == 200 + user.refresh_from_db() + assert user.subsonic_api_token != 'test' + assert user.subsonic_api_token is not None + assert response.data == { + 'subsonic_api_token': user.subsonic_api_token + } + + +def test_user_can_get_new_subsonic_token(logged_in_api_client): + user = logged_in_api_client.user + user.subsonic_api_token = 'test' + user.save() + + url = reverse( + 'api:v1:users:users-subsonic-token', + kwargs={'username': user.username}) + + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == { + 'subsonic_api_token': 'test' + } +def test_user_can_request_new_subsonic_token(logged_in_api_client): + user = logged_in_api_client.user + user.subsonic_api_token = 'test' + user.save() + + url = reverse( + 'api:v1:users:users-subsonic-token', + kwargs={'username': user.username}) + + response = logged_in_api_client.post(url) + + assert response.status_code == 200 + user.refresh_from_db() + assert user.subsonic_api_token != 'test' + assert user.subsonic_api_token is not None + assert response.data == { + 'subsonic_api_token': user.subsonic_api_token + } + + +def test_user_can_delete_subsonic_token(logged_in_api_client): + user = logged_in_api_client.user + user.subsonic_api_token = 'test' + user.save() + + url = reverse( + 'api:v1:users:users-subsonic-token', + kwargs={'username': user.username}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + user.refresh_from_db() + assert user.subsonic_api_token is None + + @pytest.mark.parametrize('method', ['put', 'patch']) def test_user_cannot_patch_another_user( method, logged_in_api_client, factories): diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index 8eeae85a9..5468358ae 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -26,6 +26,10 @@

+
+ {{ $t('Changing your password will also change your Subsonic API password if you have requested one.') }} + {{ $t('You will have to update your password on your clients that use this password.') }} +
@@ -41,10 +45,25 @@
-
- + + {{ $t('Change password') }} +

{{ $t('Change your password?') }}

+
+

{{ $t("Changing your password will have the following consequences") }}

+
    +
  • {{ $t('You will be logged out from this session and have to log out with the new one') }}
  • +
  • {{ $t('Your Subsonic password will be changed to a new, random one, logging you out from devices that used the old Subsonic password') }}
  • +
+
+

{{ $t('Disable access') }}

+
+
@@ -55,10 +74,12 @@ import $ from 'jquery' import axios from 'axios' import logger from '@/logging' import PasswordInput from '@/components/forms/PasswordInput' +import SubsonicTokenForm from '@/components/auth/SubsonicTokenForm' export default { components: { - PasswordInput + PasswordInput, + SubsonicTokenForm }, data () { let d = { diff --git a/front/src/components/auth/SubsonicTokenForm.vue b/front/src/components/auth/SubsonicTokenForm.vue new file mode 100644 index 000000000..dd0bd5cae --- /dev/null +++ b/front/src/components/auth/SubsonicTokenForm.vue @@ -0,0 +1,137 @@ + + + + + + From a5ebf56f1136df3cfc16da466ac884c00f106ab3 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 9 May 2018 23:04:10 +0200 Subject: [PATCH 13/15] See #75: User documentation --- docs/index.rst | 1 + docs/users/apps.rst | 92 +++++++++++++++++++ docs/users/index.rst | 16 ++++ .../src/components/auth/SubsonicTokenForm.vue | 2 +- 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 docs/users/apps.rst create mode 100644 docs/users/index.rst diff --git a/docs/index.rst b/docs/index.rst index 481690b70..01f76d3cc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,6 +11,7 @@ Funkwhale is a self-hosted, modern free and open-source music server, heavily in .. toctree:: :maxdepth: 2 + users/index features installation/index configuration diff --git a/docs/users/apps.rst b/docs/users/apps.rst new file mode 100644 index 000000000..f01af9266 --- /dev/null +++ b/docs/users/apps.rst @@ -0,0 +1,92 @@ +Using Funkwhale from other apps +=============================== + +As of today, the only official client for using Funkwhale is the web client, +the one you use in your browser. + +While the web client works okay, it's still not ready for some use cases, especially: + +- Usage on narrow/touche screens (smartphones, tablets) +- Usage on the go, with an intermittent connexion + +This pages lists alternative clients you can use to connect to your Funkwhale +instance and enjoy your music. + + +Subsonic-compatible clients +--------------------------- + +Since version 0.12, Funkwhale implements a subset of the `Subsonic API `_. +This API is a de-facto standard for a lot of projects out there, and many clients +are available that works with this API. + +Those Subsonic features are supported in Funkwhale: + +- Search (artists, albums, tracks) +- Common library browsing using ID3 tags (list artists, albums, etc.) +- Playlist management +- Stars (which is mapped to Funkwhale's favorites) + +Those features as missing: + +- Transcoding/streaming with different bitrates +- Album covers +- Artist info (this data is not available in Funkwhale) +- Library browsing that relies music directories +- Bookmarks +- Admin +- Chat +- Shares + +.. note:: + + If you know or use some recent, well-maintained, Subsonic clients, + please get in touch so we can add them to this list. + + Especially we're still lacking an iOS client! + + +Enabling Subsonic on your Funkwhale account +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To log-in on your Funkwhale account from Subsonic clients, you will need to +set a separate Subsonic API password by visiting your settings page. + +Then, when using a client, you'll have to input some information about your server: + +1. Your Funkwhale instance URL (e.g. https://demo.funkwhale.audio) +2. Your Funkwhale username (e.g. demo) +3. Your Subsonic API password (the one you set earlier in this section) + +In your client configuration, please double check the "ID3" or "Browse with tags" +setting is enabled. + +DSub (Android) +^^^^^^^^^^^^^^ + +- Price: free (on F-Droid) +- F-Droid: https://f-droid.org/en/packages/github.daneren2005.dsub/ +- Google Play: https://play.google.com/store/apps/details?id=github.daneren2005.dsub +- Sources: https://github.com/daneren2005/Subsonic + +DSub is a full-featured Subsonic client that works great, and has a lot of features: + +- Playlists +- Stars +- Search +- Offline cache (with configurable size, playlist download, queue prefetching, etc.) + +It's the recommended Android client to use with Funkwhale, as we are doing +our Android tests on this one. + +Clementine (Desktop) +^^^^^^^^^^^^^^^^^^^^ + +- Price: free +- Website: https://www.clementine-player.org/fr/ + +This desktop client works on Windows, Mac OS X and Linux and is able to stream +music from your Funkwhale instance. However, it does not implement advanced +features such as playlist management, search or stars. + +This is the client we use for our desktop tests. diff --git a/docs/users/index.rst b/docs/users/index.rst new file mode 100644 index 000000000..215fe959e --- /dev/null +++ b/docs/users/index.rst @@ -0,0 +1,16 @@ +.. funkwhale documentation master file, created by + sphinx-quickstart on Sun Jun 25 18:49:23 2017. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Funkwhale's users documentation +===================================== + +.. note:: + + This documentation is meant for end-users of the platform. + +.. toctree:: + :maxdepth: 2 + + apps diff --git a/front/src/components/auth/SubsonicTokenForm.vue b/front/src/components/auth/SubsonicTokenForm.vue index dd0bd5cae..1fa4b5d1d 100644 --- a/front/src/components/auth/SubsonicTokenForm.vue +++ b/front/src/components/auth/SubsonicTokenForm.vue @@ -11,7 +11,7 @@

{{ $t('However, accessing Funkwhale from those clients require a separate password you can set below.') }}

-

+

{{ $t('Discover how to use Funkwhale from other apps') }}

From 60412ad51aa9e285dbb87c07a8f13a4a3796fb6d Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 9 May 2018 23:19:58 +0200 Subject: [PATCH 14/15] Fix #75: changelog --- changes/changelog.d/75.feature | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 changes/changelog.d/75.feature diff --git a/changes/changelog.d/75.feature b/changes/changelog.d/75.feature new file mode 100644 index 000000000..e603b0810 --- /dev/null +++ b/changes/changelog.d/75.feature @@ -0,0 +1,43 @@ +Subsonic API implementation to offer compatibility with existing clients such as DSub (#75) + +Subsonic API +^^^^^^^^^^^^ + +This release implements some core parts of the Subsonic API, which is widely +deployed in various projects and supported by numerous clients. + +By offering this API in Funkwhale, we make it possible to access the instance +library and listen to the music without from existing Subsonic clients, and +without developping our own alternative clients for each and every platform. + +Most advanced Subsonic clients support offline caching of music files, +playlist management and search, which makes them well-suited for nomadic use. + +Please head over :doc:`users/apps` for more informations about supported clients +and user instructions. + +At the instance-level, the Subsonic API is enabled by default, but require +and additional endpoint to be added in you reverse-proxy configuration. + +On nginx, add the following block:: + + location /rest/ { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api/api/subsonic/rest/; + } + +On Apache, add the following block:: + + + ProxyPass ${funkwhale-api}/api/subsonic/rest + ProxyPassReverse ${funkwhale-api}/api/subsonic/rest + + +The Subsonic can be disabled at the instance level from the django admin. + +.. note:: + + Because of Subsonic's API design which assumes cleartext storing of + user passwords, we chose to have a dedicated, separate password + for that purpose. Users can generate this password from their + settings page in the web client. From 12638e1d8245f385c469801b1c437e67650755c5 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 9 May 2018 23:24:51 +0200 Subject: [PATCH 15/15] Updated demo script --- api/demo/demo-user.py | 1 + demo/setup.sh | 13 +++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/api/demo/demo-user.py b/api/demo/demo-user.py index 64f48f9aa..4f8648fb3 100644 --- a/api/demo/demo-user.py +++ b/api/demo/demo-user.py @@ -3,4 +3,5 @@ from funkwhale_api.users.models import User u = User.objects.create(email='demo@demo.com', username='demo', is_staff=True) u.set_password('demo') +u.subsonic_api_token = 'demo' u.save() diff --git a/demo/setup.sh b/demo/setup.sh index 1d63cc181..b96f517b3 100644 --- a/demo/setup.sh +++ b/demo/setup.sh @@ -5,7 +5,7 @@ demo_path="/srv/funkwhale-demo/demo" echo 'Cleaning everything...' cd $demo_path -docker-compose down -v || echo 'Nothing to stop' +/usr/local/bin/docker-compose down -v || echo 'Nothing to stop' rm -rf /srv/funkwhale-demo/demo/* mkdir -p $demo_path echo 'Downloading demo files...' @@ -23,9 +23,10 @@ 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 - -docker-compose pull -docker-compose up -d postgres redis +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 -docker-compose run --rm api demo/load-demo-data.sh -docker-compose up -d +/usr/local/bin/docker-compose run --rm api demo/load-demo-data.sh +/usr/local/bin/docker-compose up -d