diff --git a/CHANGELOG b/CHANGELOG index 82c867bf8..ba9b9f1ae 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -10,7 +10,155 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog. .. towncrier -0.11 (unreleased) +0.12 (2018-05-09) +----------------- + +Upgrade instructions are available at + https://docs.funkwhale.audio/upgrading.html + +Features: + +- Subsonic API implementation to offer compatibility with existing clients such + as DSub (#75) +- Use nodeinfo standard for publishing instance information (#192) + + +Enhancements: + +- Play button now play tracks immediately instead of appending them to the + queue (#99, #156) + + +Bugfixes: + +- Fix broken federated import (#193) + + +Documentation: + +- Up-to-date documentation for upgrading front-end files on docker setup (#132) + + +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. + + +Nodeinfo standard for instance information and stats +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. warning:: + + The ``/api/v1/instance/stats/`` endpoint which was used to display + instance data in the about page is removed in favor of the new + ``/api/v1/instance/nodeinfo/2.0/`` endpoint. + +In earlier version, we where using a custom endpoint and format for +our instance information and statistics. While this was working, +this was not compatible with anything else on the fediverse. + +We now offer a nodeinfo 2.0 endpoint which provides, in a single place, +all the instance information such as library and user activity statistics, +public instance settings (description, registration and federation status, etc.). + +We offer two settings to manage nodeinfo in your Funkwhale instance: + +1. One setting to completely disable nodeinfo, but this is not recommended + as the exposed data may be needed to make some parts of the front-end + work (especially the about page). +2. One setting to disable only usage and library statistics in the nodeinfo + endpoint. This is useful if you want the nodeinfo endpoint to work, + but don't feel comfortable sharing aggregated statistics about your library + and user activity. + +To make your instance fully compatible with the nodeinfo protocol, you need to +to edit your nginx configuration file: + +.. code-block:: + + # before + ... + location /.well-known/webfinger { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api/.well-known/webfinger; + } + ... + + # after + ... + location /.well-known/ { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api/.well-known/; + } + ... + +You can do the same if you use apache: + +.. code-block:: + + # before + ... + + ProxyPass ${funkwhale-api}/.well-known/webfinger + ProxyPassReverse ${funkwhale-api}/.well-known/webfinger + + ... + + # after + ... + + ProxyPass ${funkwhale-api}/.well-known/ + ProxyPassReverse ${funkwhale-api}/.well-known/ + + ... + +This will ensure all well-known endpoints are proxied to funkwhale, and +not just webfinger one. + +Links: + +- About nodeinfo: https://github.com/jhass/nodeinfo + + +0.11 (2018-05-06) ----------------- Upgrade instructions are available at https://docs.funkwhale.audio/upgrading.html 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/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/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/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py index 4f62dd9b5..f8b8af412 100644 --- a/api/funkwhale_api/__init__.py +++ b/api/funkwhale_api/__init__.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = '0.11' +__version__ = '0.12' __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')]) diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 9b51a534d..ef581408c 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -85,13 +85,31 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet): return response.Response({}, status=200) -class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet): +class WellKnownViewSet(viewsets.GenericViewSet): authentication_classes = [] permission_classes = [] renderer_classes = [renderers.WebfingerRenderer] + @list_route(methods=['get']) + def nodeinfo(self, request, *args, **kwargs): + if not preferences.get('instance__nodeinfo_enabled'): + return HttpResponse(status=404) + data = { + 'links': [ + { + 'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0', + 'href': utils.full_url( + reverse('api:v1:instance:nodeinfo-2.0') + ) + } + ] + } + return response.Response(data) + @list_route(methods=['get']) def webfinger(self, request, *args, **kwargs): + if not preferences.get('federation__enabled'): + return HttpResponse(status=405) try: resource_type, resource = webfinger.clean_resource( request.GET['resource']) diff --git a/api/funkwhale_api/instance/dynamic_preferences_registry.py b/api/funkwhale_api/instance/dynamic_preferences_registry.py index 1d11a2988..03555b0be 100644 --- a/api/funkwhale_api/instance/dynamic_preferences_registry.py +++ b/api/funkwhale_api/instance/dynamic_preferences_registry.py @@ -68,3 +68,31 @@ class RavenEnabled(types.BooleanPreference): 'Wether error reporting to a Sentry instance using raven is enabled' ' for front-end errors' ) + + +@global_preferences_registry.register +class InstanceNodeinfoEnabled(types.BooleanPreference): + show_in_api = False + section = instance + name = 'nodeinfo_enabled' + default = True + verbose_name = 'Enable nodeinfo endpoint' + help_text = ( + 'This endpoint is needed for your about page to work.' + 'It\'s also helpful for the various monitoring ' + 'tools that map and analyzize the fediverse, ' + 'but you can disable it completely if needed.' + ) + + +@global_preferences_registry.register +class InstanceNodeinfoStatsEnabled(types.BooleanPreference): + show_in_api = False + section = instance + name = 'nodeinfo_stats_enabled' + default = True + verbose_name = 'Enable usage and library stats in nodeinfo endpoint' + help_text = ( + 'Disable this f you don\'t want to share usage and library statistics' + 'in the nodeinfo endpoint but don\'t want to disable it completely.' + ) diff --git a/api/funkwhale_api/instance/nodeinfo.py b/api/funkwhale_api/instance/nodeinfo.py new file mode 100644 index 000000000..e267f197d --- /dev/null +++ b/api/funkwhale_api/instance/nodeinfo.py @@ -0,0 +1,73 @@ +import memoize.djangocache + +import funkwhale_api +from funkwhale_api.common import preferences + +from . import stats + + +store = memoize.djangocache.Cache('default') +memo = memoize.Memoizer(store, namespace='instance:stats') + + +def get(): + share_stats = preferences.get('instance__nodeinfo_stats_enabled') + data = { + 'version': '2.0', + 'software': { + 'name': 'funkwhale', + 'version': funkwhale_api.__version__ + }, + 'protocols': ['activitypub'], + 'services': { + 'inbound': [], + 'outbound': [] + }, + 'openRegistrations': preferences.get('users__registration_enabled'), + 'usage': { + 'users': { + 'total': 0, + } + }, + 'metadata': { + 'shortDescription': preferences.get('instance__short_description'), + 'longDescription': preferences.get('instance__long_description'), + 'nodeName': preferences.get('instance__name'), + 'library': { + 'federationEnabled': preferences.get('federation__enabled'), + 'federationNeedsApproval': preferences.get('federation__music_needs_approval'), + 'anonymousCanListen': preferences.get('common__api_authentication_required'), + }, + } + } + if share_stats: + getter = memo( + lambda: stats.get(), + max_age=600 + ) + statistics = getter() + data['usage']['users']['total'] = statistics['users'] + data['metadata']['library']['tracks'] = { + 'total': statistics['tracks'], + } + data['metadata']['library']['artists'] = { + 'total': statistics['artists'], + } + data['metadata']['library']['albums'] = { + 'total': statistics['albums'], + } + data['metadata']['library']['music'] = { + 'hours': statistics['music_duration'] + } + + data['metadata']['usage'] = { + 'favorites': { + 'tracks': { + 'total': statistics['track_favorites'], + } + }, + 'listenings': { + 'total': statistics['listenings'] + } + } + return data diff --git a/api/funkwhale_api/instance/urls.py b/api/funkwhale_api/instance/urls.py index af23e7e08..f506488fc 100644 --- a/api/funkwhale_api/instance/urls.py +++ b/api/funkwhale_api/instance/urls.py @@ -1,11 +1,9 @@ from django.conf.urls import url -from django.views.decorators.cache import cache_page from . import views urlpatterns = [ + url(r'^nodeinfo/2.0/$', views.NodeInfo.as_view(), name='nodeinfo-2.0'), url(r'^settings/$', views.InstanceSettings.as_view(), name='settings'), - url(r'^stats/$', - cache_page(60 * 5)(views.InstanceStats.as_view()), name='stats'), ] diff --git a/api/funkwhale_api/instance/views.py b/api/funkwhale_api/instance/views.py index 7f8f393c9..5953ca555 100644 --- a/api/funkwhale_api/instance/views.py +++ b/api/funkwhale_api/instance/views.py @@ -4,9 +4,17 @@ from rest_framework.response import Response from dynamic_preferences.api import serializers from dynamic_preferences.registries import global_preferences_registry +from funkwhale_api.common import preferences + +from . import nodeinfo from . import stats +NODEINFO_2_CONTENT_TYPE = ( + 'application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8' # noqa +) + + class InstanceSettings(views.APIView): permission_classes = [] authentication_classes = [] @@ -27,10 +35,13 @@ class InstanceSettings(views.APIView): return Response(data, status=200) -class InstanceStats(views.APIView): +class NodeInfo(views.APIView): permission_classes = [] authentication_classes = [] def get(self, request, *args, **kwargs): - data = stats.get() - return Response(data, status=200) + if not preferences.get('instance__nodeinfo_enabled'): + return Response(status=404) + data = nodeinfo.get() + return Response( + data, status=200, content_type=NODEINFO_2_CONTENT_TYPE) 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..5ee5d851d 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 @@ -106,7 +112,7 @@ class Artist(APIModelMixin): kwargs.update({'name': name}) return cls.objects.get_or_create( name__iexact=name, - defaults=kwargs)[0] + defaults=kwargs) def import_artist(v): @@ -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)) @@ -196,7 +208,7 @@ class Album(APIModelMixin): kwargs.update({'title': title}) return cls.objects.get_or_create( title__iexact=title, - defaults=kwargs)[0] + defaults=kwargs) def import_tags(instance, cleaned_data, raw_data): @@ -403,7 +415,7 @@ class Track(APIModelMixin): kwargs.update({'title': title}) return cls.objects.get_or_create( title__iexact=title, - defaults=kwargs)[0] + defaults=kwargs) class TrackFile(models.Model): @@ -457,7 +469,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/tasks.py b/api/funkwhale_api/music/tasks.py index 4509c9a57..bad0006aa 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -39,7 +39,7 @@ def import_track_from_remote(library_track): except (KeyError, AssertionError): pass else: - return models.Track.get_or_create_from_api(mbid=track_mbid) + return models.Track.get_or_create_from_api(mbid=track_mbid)[0] try: album_mbid = metadata['release']['musicbrainz_id'] @@ -47,9 +47,9 @@ def import_track_from_remote(library_track): except (KeyError, AssertionError): pass else: - album = models.Album.get_or_create_from_api(mbid=album_mbid) + album, _ = models.Album.get_or_create_from_api(mbid=album_mbid) return models.Track.get_or_create_from_title( - library_track.title, artist=album.artist, album=album) + library_track.title, artist=album.artist, album=album)[0] try: artist_mbid = metadata['artist']['musicbrainz_id'] @@ -57,20 +57,20 @@ def import_track_from_remote(library_track): except (KeyError, AssertionError): pass else: - artist = models.Artist.get_or_create_from_api(mbid=artist_mbid) - album = models.Album.get_or_create_from_title( + artist, _ = models.Artist.get_or_create_from_api(mbid=artist_mbid) + album, _ = models.Album.get_or_create_from_title( library_track.album_title, artist=artist) return models.Track.get_or_create_from_title( - library_track.title, artist=artist, album=album) + library_track.title, artist=artist, album=album)[0] # worst case scenario, we have absolutely no way to link to a # musicbrainz resource, we rely on the name/titles - artist = models.Artist.get_or_create_from_name( + artist, _ = models.Artist.get_or_create_from_name( library_track.artist_name) - album = models.Album.get_or_create_from_title( + album, _ = models.Album.get_or_create_from_title( library_track.album_title, artist=artist) return models.Track.get_or_create_from_title( - library_track.title, artist=artist, album=album) + library_track.title, artist=artist, album=album)[0] def _do_import(import_job, replace=False, use_acoustid=True): 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/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 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/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/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/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..5bc452886 --- /dev/null +++ b/api/funkwhale_api/subsonic/serializers.py @@ -0,0 +1,215 @@ +import collections + +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): + payload = { + 'ignoredArticles': '', + 'index': [] + } + queryset = queryset.with_albums_count() + 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': [ + get_artist_data(v) + 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 + + +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 or 1, + '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').select_related('album') + payload = get_album2_data(album) + if album.release_date: + payload['year'] = album.release_date.year + + 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 + ] + + +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 + + +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 new file mode 100644 index 000000000..475e61aa7 --- /dev/null +++ b/api/funkwhale_api/subsonic/views.py @@ -0,0 +1,498 @@ +import datetime + +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 +from funkwhale_api.music import views as music_views +from funkwhale_api.playlists import models as playlists_models + +from . import authentication +from . import filters +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) + }) + qs = queryset + if hasattr(qs, '__call__'): + qs = qs(request) + try: + obj = qs.get(**{model_field: value}) + except qs.model.DoesNotExist: + return response.Response({ + 'code': 70, + 'message': '{} not found'.format( + qs.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 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 + 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_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', + 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_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', + 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_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', + 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.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 = { + '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) + + @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) + + @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) + + 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' + } + 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 + ) + 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 = { + '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/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/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/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/federation/test_views.py b/api/tests/federation/test_views.py index 09ecfc8ff..cc81f0657 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -70,6 +70,32 @@ def test_wellknown_webfinger_system( assert response.data == serializer.data +def test_wellknown_nodeinfo(db, preferences, api_client, settings): + expected = { + 'links': [ + { + 'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0', + 'href': '{}{}'.format( + settings.FUNKWHALE_URL, + reverse('api:v1:instance:nodeinfo-2.0') + ) + } + ] + } + url = reverse('federation:well-known-nodeinfo') + response = api_client.get(url) + assert response.status_code == 200 + assert response['Content-Type'] == 'application/jrd+json' + assert response.data == expected + + +def test_wellknown_nodeinfo_disabled(db, preferences, api_client): + preferences['instance__nodeinfo_enabled'] = False + url = reverse('federation:well-known-nodeinfo') + response = api_client.get(url) + assert response.status_code == 404 + + def test_audio_file_list_requires_authenticated_actor( db, preferences, api_client): preferences['federation__music_needs_approval'] = True diff --git a/api/tests/instance/test_nodeinfo.py b/api/tests/instance/test_nodeinfo.py new file mode 100644 index 000000000..4ca1c43a5 --- /dev/null +++ b/api/tests/instance/test_nodeinfo.py @@ -0,0 +1,105 @@ +from django.urls import reverse + +import funkwhale_api + +from funkwhale_api.instance import nodeinfo + + +def test_nodeinfo_dump(preferences, mocker): + preferences['instance__nodeinfo_stats_enabled'] = True + stats = { + 'users': 1, + 'tracks': 2, + 'albums': 3, + 'artists': 4, + 'track_favorites': 5, + 'music_duration': 6, + 'listenings': 7, + } + mocker.patch('funkwhale_api.instance.stats.get', return_value=stats) + + expected = { + 'version': '2.0', + 'software': { + 'name': 'funkwhale', + 'version': funkwhale_api.__version__ + }, + 'protocols': ['activitypub'], + 'services': { + 'inbound': [], + 'outbound': [] + }, + 'openRegistrations': preferences['users__registration_enabled'], + 'usage': { + 'users': { + 'total': stats['users'], + } + }, + 'metadata': { + 'shortDescription': preferences['instance__short_description'], + 'longDescription': preferences['instance__long_description'], + 'nodeName': preferences['instance__name'], + 'library': { + 'federationEnabled': preferences['federation__enabled'], + 'federationNeedsApproval': preferences['federation__music_needs_approval'], + 'anonymousCanListen': preferences['common__api_authentication_required'], + 'tracks': { + 'total': stats['tracks'], + }, + 'artists': { + 'total': stats['artists'], + }, + 'albums': { + 'total': stats['albums'], + }, + 'music': { + 'hours': stats['music_duration'] + }, + }, + 'usage': { + 'favorites': { + 'tracks': { + 'total': stats['track_favorites'], + } + }, + 'listenings': { + 'total': stats['listenings'] + } + } + } + } + assert nodeinfo.get() == expected + + +def test_nodeinfo_dump_stats_disabled(preferences, mocker): + preferences['instance__nodeinfo_stats_enabled'] = False + + expected = { + 'version': '2.0', + 'software': { + 'name': 'funkwhale', + 'version': funkwhale_api.__version__ + }, + 'protocols': ['activitypub'], + 'services': { + 'inbound': [], + 'outbound': [] + }, + 'openRegistrations': preferences['users__registration_enabled'], + 'usage': { + 'users': { + 'total': 0, + } + }, + 'metadata': { + 'shortDescription': preferences['instance__short_description'], + 'longDescription': preferences['instance__long_description'], + 'nodeName': preferences['instance__name'], + 'library': { + 'federationEnabled': preferences['federation__enabled'], + 'federationNeedsApproval': preferences['federation__music_needs_approval'], + 'anonymousCanListen': preferences['common__api_authentication_required'], + }, + } + } + assert nodeinfo.get() == expected diff --git a/api/tests/instance/test_stats.py b/api/tests/instance/test_stats.py index 6eaad76f7..6063e9300 100644 --- a/api/tests/instance/test_stats.py +++ b/api/tests/instance/test_stats.py @@ -3,16 +3,6 @@ from django.urls import reverse from funkwhale_api.instance import stats -def test_can_get_stats_via_api(db, api_client, mocker): - stats = { - 'foo': 'bar' - } - mocker.patch('funkwhale_api.instance.stats.get', return_value=stats) - url = reverse('api:v1:instance:stats') - response = api_client.get(url) - assert response.data == stats - - def test_get_users(mocker): mocker.patch( 'funkwhale_api.users.models.User.objects.count', return_value=42) diff --git a/api/tests/instance/test_views.py b/api/tests/instance/test_views.py new file mode 100644 index 000000000..468c0ddae --- /dev/null +++ b/api/tests/instance/test_views.py @@ -0,0 +1,23 @@ +from django.urls import reverse + + +def test_nodeinfo_endpoint(db, api_client, mocker): + payload = { + 'test': 'test' + } + mocked_nodeinfo = mocker.patch( + 'funkwhale_api.instance.nodeinfo.get', return_value=payload) + url = reverse('api:v1:instance:nodeinfo-2.0') + response = api_client.get(url) + ct = 'application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8' # noqa + assert response.status_code == 200 + assert response['Content-Type'] == ct + assert response.data == payload + + +def test_nodeinfo_endpoint_disabled(db, api_client, preferences): + preferences['instance__nodeinfo_enabled'] = False + url = reverse('api:v1:instance:nodeinfo-2.0') + response = api_client.get(url) + + assert response.status_code == 404 diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py index 000e6a8b6..c7b40fb16 100644 --- a/api/tests/music/test_import.py +++ b/api/tests/music/test_import.py @@ -66,7 +66,7 @@ def test_import_job_from_federation_musicbrainz_recording(factories, mocker): t = factories['music.Track']() track_from_api = mocker.patch( 'funkwhale_api.music.models.Track.get_or_create_from_api', - return_value=t) + return_value=(t, True)) lt = factories['federation.LibraryTrack']( metadata__recording__musicbrainz=True, artist_name='Hello', @@ -92,7 +92,7 @@ def test_import_job_from_federation_musicbrainz_release(factories, mocker): a = factories['music.Album']() album_from_api = mocker.patch( 'funkwhale_api.music.models.Album.get_or_create_from_api', - return_value=a) + return_value=(a, True)) lt = factories['federation.LibraryTrack']( metadata__release__musicbrainz=True, artist_name='Hello', @@ -121,7 +121,7 @@ def test_import_job_from_federation_musicbrainz_artist(factories, mocker): a = factories['music.Artist']() artist_from_api = mocker.patch( 'funkwhale_api.music.models.Artist.get_or_create_from_api', - return_value=a) + return_value=(a, True)) lt = factories['federation.LibraryTrack']( metadata__artist__musicbrainz=True, album_title='World', 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..6da9dd12e --- /dev/null +++ b/api/tests/subsonic/test_serializers.py @@ -0,0 +1,207 @@ +from funkwhale_api.music import models as music_models +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 or '', + 'duration': tf.duration or 0, + 'created': track.creation_date, + 'albumId': album.pk, + 'artistId': artist.pk, + 'type': 'music', + } + ] + } + + 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 + + +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 + + +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 new file mode 100644 index 000000000..bd445e070 --- /dev/null +++ b/api/tests/subsonic/test_views.py @@ -0,0 +1,393 @@ +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 +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 + + +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') + 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') + 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, {'f': f}) + + 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_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') + 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 + + +@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 == { + '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]) + } + } + + +@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]), + } + } + + +@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 + track1 = factories['music.Track']() + track2 = factories['music.Track']() + response = logged_in_api_client.get( + url, {'f': f, 'name': 'hello', 'songId': [track1.pk, track2.pk]}) + assert response.status_code == 200 + playlist = logged_in_api_client.user.playlists.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' + qs = playlist.__class__.objects.with_tracks_count() + 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 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' 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/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 diff --git a/deploy/apache.conf b/deploy/apache.conf index 8d5a5e1f7..5bfcbce04 100644 --- a/deploy/apache.conf +++ b/deploy/apache.conf @@ -84,9 +84,9 @@ Define MUSIC_DIRECTORY_PATH /srv/funkwhale/data/music ProxyPassReverse ${funkwhale-api}/federation - - ProxyPass ${funkwhale-api}/.well-known/webfinger - ProxyPassReverse ${funkwhale-api}/.well-known/webfinger + + ProxyPass ${funkwhale-api}/.well-known/ + ProxyPassReverse ${funkwhale-api}/.well-known/ Alias /media /srv/funkwhale/data/media diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample index 4b27595af..42659a0da 100644 --- a/deploy/env.prod.sample +++ b/deploy/env.prod.sample @@ -48,9 +48,9 @@ FUNKWHALE_URL=https://yourdomain.funwhale # EMAIL_CONFIG=consolemail:// # output emails to console (the default) # EMAIL_CONFIG=dummymail:// # disable email sending completely # On a production instance, you'll usually want to use an external SMTP server: -# EMAIL_CONFIG=smtp://user@:password@youremail.host:25' -# EMAIL_CONFIG=smtp+ssl://user@:password@youremail.host:465' -# EMAIL_CONFIG=smtp+tls://user@:password@youremail.host:587' +# EMAIL_CONFIG=smtp://user@:password@youremail.host:25 +# EMAIL_CONFIG=smtp+ssl://user@:password@youremail.host:465 +# EMAIL_CONFIG=smtp+tls://user@:password@youremail.host:587 # The email address to use to send systme emails. By default, we will # DEFAULT_FROM_EMAIL=noreply@yourdomain diff --git a/deploy/nginx.conf b/deploy/nginx.conf index b3a4c6aaf..7d344408b 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -67,9 +67,9 @@ server { proxy_pass http://funkwhale-api/federation/; } - location /.well-known/webfinger { + location /.well-known/ { include /etc/nginx/funkwhale_proxy.conf; - proxy_pass http://funkwhale-api/.well-known/webfinger; + proxy_pass http://funkwhale-api/.well-known/; } location /media/ { 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/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/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 ################################################################ 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/installation/index.rst b/docs/installation/index.rst index a3e11529b..ae5794b6c 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -77,7 +77,8 @@ Frontend setup .. note:: You do not need to do this if you are deploying using Docker, as frontend files - are already included in the funkwhale docker image. + are already included in the docker image. + Files for the web frontend are purely static and can simply be downloaded, unzipped and served from any webserver: diff --git a/docs/upgrading.rst b/docs/upgrading.rst index 674878ba7..bd3d5578f 100644 --- a/docs/upgrading.rst +++ b/docs/upgrading.rst @@ -17,29 +17,9 @@ Please take a few minutes to read the :doc:`changelog`: updates should work similarly from version to version, but some of them may require additional steps. Those steps would be described in the version release notes. -Upgrade the static files ------------------------- - -Regardless of your deployment choice (docker/non-docker) the front-end app -is updated separately from the API. This is as simple as downloading -the zip with the static files and extracting it in the correct place. - -The following example assume your setup match :ref:`frontend-setup`. - -.. parsed-literal:: - - # this assumes you want to upgrade to version "|version|" - export FUNKWHALE_VERSION="|version|" - cd /srv/funkwhale - curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front" - unzip -o front.zip - rm front.zip - -Upgrading the API ------------------ Docker setup -^^^^^^^^^^^^ +------------ If you've followed the setup instructions in :doc:`Docker`, upgrade path is easy: @@ -57,10 +37,33 @@ easy: # Relaunch the containers docker-compose up -d -Non-docker setup -^^^^^^^^^^^^^^^^ -On non docker-setup, upgrade involves a few more commands. We assume your setup + +Non-docker setup +---------------- + +Upgrade the static files +^^^^^^^^^^^^^^^^^^^^^^^^ + +On non-docker setups, the front-end app +is updated separately from the API. This is as simple as downloading +the zip with the static files and extracting it in the correct place. + +The following example assume your setup match :ref:`frontend-setup`. + +.. parsed-literal:: + + # this assumes you want to upgrade to version "|version|" + export FUNKWHALE_VERSION="|version|" + cd /srv/funkwhale + curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front" + unzip -o front.zip + rm front.zip + +Upgrading the API +^^^^^^^^^^^^^^^^^ + +On non-docker, upgrade involves a few more commands. We assume your setup match what is described in :doc:`debian`: .. parsed-literal:: 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/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}`) }) diff --git a/front/src/components/Home.vue b/front/src/components/Home.vue index 03f4513e6..2af1bfc35 100644 --- a/front/src/components/Home.vue +++ b/front/src/components/Home.vue @@ -5,7 +5,7 @@

{{ $t('Welcome on Funkwhale') }}

-

{{ $t('We think listening music should be simple.') }}

+

{{ $t('We think listening to music should be simple.') }}

{{ $t('Learn more about this instance') }} diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 14d381ca1..2662f30b3 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -2,7 +2,7 @@
+ + {{ $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..1fa4b5d1d --- /dev/null +++ b/front/src/components/auth/SubsonicTokenForm.vue @@ -0,0 +1,137 @@ + + + + + + diff --git a/front/src/components/instance/Stats.vue b/front/src/components/instance/Stats.vue index 7da9fc6ed..ac144ceb3 100644 --- a/front/src/components/instance/Stats.vue +++ b/front/src/components/instance/Stats.vue @@ -3,7 +3,7 @@

-
+
@@ -19,7 +19,7 @@
- {{ stats.track_favorites }} + {{ stats.trackFavorites }}
@@ -30,7 +30,7 @@
- {{ parseInt(stats.music_duration) }} + {{ parseInt(stats.musicDuration) }}
@@ -59,6 +59,7 @@