diff --git a/CHANGELOG b/CHANGELOG index 82c867bf890d619bd9c10177a75f906a4445c56a..ba9b9f1ae0dbfac417fdb857eb46fbdb7718d744 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:: + + <Location "/rest"> + ProxyPass ${funkwhale-api}/api/subsonic/rest + ProxyPassReverse ${funkwhale-api}/api/subsonic/rest + </Location> + +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 + ... + <Location "/.well-known/webfinger"> + ProxyPass ${funkwhale-api}/.well-known/webfinger + ProxyPassReverse ${funkwhale-api}/.well-known/webfinger + </Location> + ... + + # after + ... + <Location "/.well-known/"> + ProxyPass ${funkwhale-api}/.well-known/ + ProxyPassReverse ${funkwhale-api}/.well-known/ + </Location> + ... + +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 cf5b03744d8b12d5a91fd43df4edbad941870389..e75781d14c3061892520290867d068f29991a016 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 f88aa5dd549fe09afd1c41999180e481dd2f151a..5fed9f25e86d065aff3b8f4ab662d38c65870aaf 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 64f48f9aa06bb9cb406306929d975025ad440c9b..4f8648fb37288ad6ff18d59be88ea3bac207fdcc 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 4f62dd9b5b08542e8fa55eaae0920bda4edd6296..f8b8af4126f34f3e55477c22d0c3a985ad827763 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 9b51a534df506169be39b66d75becca94bb5d90c..ef581408c2e187bf32f3922c651b903f3583884e 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 1d11a2988ca1d76da7b61d8ef8657ac6cdee74df..03555b0be57bde18f4fe13ba1b62d852ffd93ae4 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 0000000000000000000000000000000000000000..e267f197d17ce7c317128c684f1db8f72a1a47ba --- /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 af23e7e08433b97c6c10f07e1b929892e5dbe32c..f506488fc4db7819da6aae5d5c79fe33e8a9af5c 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 7f8f393c964e24bfc7a5ee29bf6be2e30f337188..5953ca555a3081d5e58a1d60da1d3dec58279e3b 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 bc0c74a2d0c108330e274d71522bfed9cccc8554..1df949904db992e2cd295d02e4acbfed9d212627 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 655d38755e1deba8e4bd3e99942c0ea7b586af5d..5ee5d851dc122f2aa632952eed2b218d76b0dfd7 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 4509c9a57e6faf11b63d21c98af189a940c30677..bad0006aa98520e2afac00aad8d5466bde2b8934 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 76fc8bc3e7fb257962c3388cd904223ff0c5d9d3..98274e741293f6ef9b4b9cf9ce5f59e32eb8815f 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 a208a5fd05e82258ad887b8f7dac06facf6f1167..f5132e12dd9d9beeb03db32090310d46b9efc6b9 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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/subsonic/authentication.py b/api/funkwhale_api/subsonic/authentication.py new file mode 100644 index 0000000000000000000000000000000000000000..fe9b08dc8afda3c0171dd671b79e9ba20ea7ca05 --- /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 0000000000000000000000000000000000000000..93482702ff110e39885dc72395836598f4e7e6bc --- /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 0000000000000000000000000000000000000000..b7b639fac319bc6f8da17ead83f23f52324c23c7 --- /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 0000000000000000000000000000000000000000..3335fda45b2d59564b6687bb686cc98b6cf93700 --- /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 0000000000000000000000000000000000000000..74cf13d887d9186a1bd50b4569fe59970daff762 --- /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'<?xml version="1.0" encoding="UTF-8"?>\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 0000000000000000000000000000000000000000..5bc452886d7486bdc0ad7c1f5571ea69fc405895 --- /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 0000000000000000000000000000000000000000..475e61aa73119ff8df9a71fa2a27cf3188dc0cff --- /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 0af155e77339177323d46f32f2ad9a7e6cf99f5c..12307f7fd109407f025b05dd364146204a59ae27 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 0000000000000000000000000000000000000000..689b3ef7791943bc92d505aa49b6289bc2f52b14 --- /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 572fa9ddca7c1457db1e6a00956c105874e19c62..773d60f38ebec50dd46cda63b05b37ac4659573c 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 7c58363a3ed7fcbdccdd86138d2081a17b564631..0cc317889196703e9435f8af36ed63fb001b887f 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 51a1bc4c7455c0be929ba503b971b3ec2d8ad244..dda537801f3cf66efb7eec4e823816e5dcec8207 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 09ecfc8ff7f6d192808890b78eb9d3226ad8cc7e..cc81f0657754960d1389bfe36bb6c02787f4c5a3 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 0000000000000000000000000000000000000000..4ca1c43a5835f94a6db20018a76e456c5e1b7e28 --- /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 6eaad76f7f9d8292211965a462f473c8bb41745a..6063e9300512819a9cc3e6d6bebcc477af9a59d7 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 0000000000000000000000000000000000000000..468c0ddae9de440b3edce7fd65fdc57c6ead8fff --- /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 000e6a8b6265eca2178c98ff52b0807956314830..c7b40fb16e9ebc50e33060b88c0c82947e808451 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 0000000000000000000000000000000000000000..724513523bd93d4c4e1a686962b5b05f03f37b0d --- /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 0000000000000000000000000000000000000000..8e2ea3f85ab80bc45661eb07e4ec69f542b8f963 --- /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 = """<?xml version="1.0" encoding="UTF-8"?> +<key hello="world"><item this="1" /><item some="node" /></key>""" + 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'<?xml version="1.0" encoding="UTF-8"?>\n<subsonic-response hello="world" status="ok" version="1.16.0" xmlns="http://subsonic.org/restapi" />' + + 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 0000000000000000000000000000000000000000..6da9dd12e2e273643cf78ec0410535e77891fdad --- /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 0000000000000000000000000000000000000000..bd445e070727c529e1295154c79302d0f37c51b1 --- /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 57793f494bcc59a6994d2f014cf9ae7090495cd6..c7cd12e9e3ba19a457fa0d66d68e6b3679925c84 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 985a78c8a65ed49853869ed18c2cb82a5b2a95db..fffc762fde7cb6f6533f3781bc73e828fa8e5d56 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 1d63cc181857cdeca2b579282eff98c816c378f5..b96f517b3a4ba7b69c137fc425aa2c7e912f820a 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 8d5a5e1f7ee45c7a02f4f8654309744f841751c7..5bfcbce04587e96fe949f21e6c50b4e05c77360e 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 </Location> - <Location "/.well-known/webfinger"> - ProxyPass ${funkwhale-api}/.well-known/webfinger - ProxyPassReverse ${funkwhale-api}/.well-known/webfinger + <Location "/.well-known/"> + ProxyPass ${funkwhale-api}/.well-known/ + ProxyPassReverse ${funkwhale-api}/.well-known/ </Location> Alias /media /srv/funkwhale/data/media diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample index 4b27595af62ae2975b5af853ce5acca406bae58a..42659a0dabc33cf2da439b21f06f77163c544722 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 b3a4c6aaf762830b533774689cb26266ca6b65e8..7d344408b67ad8c46e2d7a161180ba928ebb31aa 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 534d8f5b5d8bbde692acde76ba1a2f1b7cb9dfbc..e85ce3b91f59345982e1443a6637ef2fbb726c86 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 38c3de6c7e41369aa98bf6e3f21ad84a8d0c0a4d..ab6714e60e1b6fec7a606cc6e820e65127af7346 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 85da2ea7288a9609506d925178f79fc9a6fa4be1..c87f4527d4ee898c44529e64b35c81de7ca36a0b 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 481690b708fd49d98d79bdf4b4354d1963339935..01f76d3cc0212bee64ea8ec9956d78e880c88777 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 a3e11529b155d7148aa08840b71a4692ce035f00..ae5794b6cfd41ea2074be012d38748c2801bb06a 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 674878ba7c4d8f4dd41f64f0dbda615ea515ba2d..bd3d5578f3904ecf4edd73d9360cbcaa46d768bf 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 -^^^^^^^^^^^^^^^^ +---------------- + +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-setup, upgrade involves a few more commands. We assume your setup +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 0000000000000000000000000000000000000000..f01af9266c9bbcbb66e13edbf332957810e5c106 --- /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 <http://www.subsonic.org/pages/api.jsp>`_. +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 0000000000000000000000000000000000000000..215fe959ec700290f4ba9551d5a1866dad34253b --- /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 669ce54f37dbfe912cf61dda75b7ccb6366c0824..f4996f0203dd0d5b8f28820058c7e3c247c7474e 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 03f4513e6cb645ab17dc79c75dcee32c202ef0b8..2af1bfc35b1b65a60a686268a31594c2604ada9a 100644 --- a/front/src/components/Home.vue +++ b/front/src/components/Home.vue @@ -5,7 +5,7 @@ <h1 class="ui huge header"> {{ $t('Welcome on Funkwhale') }} </h1> - <p>{{ $t('We think listening music should be simple.') }}</p> + <p>{{ $t('We think listening to music should be simple.') }}</p> <router-link class="ui icon button" to="/about"> <i class="info icon"></i> {{ $t('Learn more about this instance') }} diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 14d381ca19517efa6641e047fc94cd6aed4f1795..2662f30b33a5321a1e2d0121c3930c46b5f66097 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -2,7 +2,7 @@ <div :class="['ui', {'tiny': discrete}, 'buttons']"> <button :title="$t('Add to current queue')" - @click="add" + @click="addNext(true)" :class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']"> <i class="ui play icon"></i> <template v-if="!discrete"><slot><i18next path="Play"/></slot></template> @@ -42,9 +42,7 @@ export default { } }, mounted () { - if (!this.discrete) { - jQuery(this.$el).find('.ui.dropdown').dropdown() - } + jQuery(this.$el).find('.ui.dropdown').dropdown() }, computed: { playable () { @@ -98,9 +96,11 @@ export default { addNext (next) { let self = this this.triggerLoad() + let wasEmpty = this.$store.state.queue.tracks.length === 0 this.getPlayableTracks().then((tracks) => { self.$store.dispatch('queue/appendMany', {tracks: tracks, index: self.$store.state.queue.currentIndex + 1}) - if (next) { + let goNext = next && !wasEmpty + if (goNext) { self.$store.dispatch('queue/next') } }) diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index 8eeae85a94a0831f2008428d92b4712547b0533a..5468358aeb438ff768dc9e66d1845c1ecdf5e34d 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -26,6 +26,10 @@ <div class="ui hidden divider"></div> <div class="ui small text container"> <h2 class="ui header"><i18next path="Change my password"/></h2> + <div class="ui message"> + {{ $t('Changing your password will also change your Subsonic API password if you have requested one.') }} + {{ $t('You will have to update your password on your clients that use this password.') }} + </div> <form class="ui form" @submit.prevent="submitPassword()"> <div v-if="passwordError" class="ui negative message"> <div class="header"><i18next path="Cannot change your password"/></div> @@ -41,10 +45,25 @@ <div class="field"> <label><i18next path="New password"/></label> <password-input required v-model="new_password" /> - </div> - <button :class="['ui', {'loading': isLoading}, 'button']" type="submit"><i18next path="Change password"/></button> + <dangerous-button + color="yellow" + :class="['ui', {'loading': isLoading}, 'button']" + :action="submitPassword"> + {{ $t('Change password') }} + <p slot="modal-header">{{ $t('Change your password?') }}</p> + <div slot="modal-content"> + <p>{{ $t("Changing your password will have the following consequences") }}</p> + <ul> + <li>{{ $t('You will be logged out from this session and have to log out with the new one') }}</li> + <li>{{ $t('Your Subsonic password will be changed to a new, random one, logging you out from devices that used the old Subsonic password') }}</li> + </ul> + </div> + <p slot="modal-confirm">{{ $t('Disable access') }}</p> + </dangerous-button> </form> + <div class="ui hidden divider" /> + <subsonic-token-form /> </div> </div> </div> @@ -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 0000000000000000000000000000000000000000..1fa4b5d1de90be9430ff4e81923b8f11d3184220 --- /dev/null +++ b/front/src/components/auth/SubsonicTokenForm.vue @@ -0,0 +1,137 @@ +<template> + <form class="ui form" @submit.prevent="requestNewToken()"> + <h2>{{ $t('Subsonic API password') }}</h2> + <p class="ui message" v-if="!subsonicEnabled"> + {{ $t('The Subsonic API is not available on this Funkwhale instance.') }} + </p> + <p> + {{ $t('Funkwhale is compatible with other music players that support the Subsonic API.') }} + {{ $t('You can use those to enjoy your playlist and music in offline mode, on your smartphone or tablet, for instance.') }} + </p> + <p> + {{ $t('However, accessing Funkwhale from those clients require a separate password you can set below.') }} + </p> + <p><a href="https://docs.funkwhale.audio/users/apps.html#subsonic-compatible-clients" target="_blank"> + {{ $t('Discover how to use Funkwhale from other apps') }} + </a></p> + <div v-if="success" class="ui positive message"> + <div class="header">{{ successMessage }}</div> + </div> + <div v-if="subsonicEnabled && errors.length > 0" class="ui negative message"> + <div class="header">{{ $t('Error') }}</div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <template v-if="subsonicEnabled"> + <div v-if="token" class="field"> + <password-input v-model="token" /> + </div> + <dangerous-button + v-if="token" + color="grey" + :class="['ui', {'loading': isLoading}, 'button']" + :action="requestNewToken"> + {{ $t('Request a new password') }} + <p slot="modal-header">{{ $t('Request a new Subsonic API password?') }}</p> + <p slot="modal-content">{{ $t('This will log you out from existing devices that use the current password.') }}</p> + <p slot="modal-confirm">{{ $t('Request a new password') }}</p> + </dangerous-button> + <button + v-else + color="grey" + :class="['ui', {'loading': isLoading}, 'button']" + @click="requestNewToken">{{ $t('Request a password') }}</button> + <dangerous-button + v-if="token" + color="yellow" + :class="['ui', {'loading': isLoading}, 'button']" + :action="disable"> + {{ $t('Disable Subsonic access') }} + <p slot="modal-header">{{ $t('Disable Subsonic API access?') }}</p> + <p slot="modal-content">{{ $t('This will completely disable access to the Subsonic API using from account.') }}</p> + <p slot="modal-confirm">{{ $t('Disable access') }}</p> + </dangerous-button> + </template> + </form> +</template> + +<script> +import axios from 'axios' +import PasswordInput from '@/components/forms/PasswordInput' + +export default { + components: { + PasswordInput + }, + data () { + return { + token: null, + errors: [], + success: false, + isLoading: false, + successMessage: '' + } + }, + created () { + this.fetchToken() + }, + methods: { + fetchToken () { + this.success = false + this.errors = [] + this.isLoading = true + let self = this + let url = `users/users/${this.$store.state.auth.username}/subsonic-token/` + return axios.get(url).then(response => { + self.token = response.data['subsonic_api_token'] + self.isLoading = false + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + requestNewToken () { + this.successMessage = this.$t('Password updated') + this.success = false + this.errors = [] + this.isLoading = true + let self = this + let url = `users/users/${this.$store.state.auth.username}/subsonic-token/` + return axios.post(url, {}).then(response => { + self.token = response.data['subsonic_api_token'] + self.isLoading = false + self.success = true + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + disable () { + this.successMessage = this.$t('Access disabled') + this.success = false + this.errors = [] + this.isLoading = true + let self = this + let url = `users/users/${this.$store.state.auth.username}/subsonic-token/` + return axios.delete(url).then(response => { + self.isLoading = false + self.token = null + self.success = true + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + } + }, + computed: { + subsonicEnabled () { + return this.$store.state.instance.settings.subsonic.enabled.value + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/components/instance/Stats.vue b/front/src/components/instance/Stats.vue index 7da9fc6ede056c4174f5dc6004f5c6233664008b..ac144ceb3a910e50e59ea9f9efd0575d2c037a0a 100644 --- a/front/src/components/instance/Stats.vue +++ b/front/src/components/instance/Stats.vue @@ -3,7 +3,7 @@ <div v-if="stats" class="ui stackable two column grid"> <div class="column"> <h3 class="ui left aligned header"><i18next path="User activity"/></h3> - <div class="ui mini horizontal statistics"> + <div v-if="stats" class="ui mini horizontal statistics"> <div class="statistic"> <div class="value"> <i class="green user icon"></i> @@ -19,7 +19,7 @@ </div> <div class="statistic"> <div class="value"> - <i class="pink heart icon"></i> {{ stats.track_favorites }} + <i class="pink heart icon"></i> {{ stats.trackFavorites }} </div> <i18next tag="div" class="label" path="Tracks favorited"/> </div> @@ -30,7 +30,7 @@ <div class="ui mini horizontal statistics"> <div class="statistic"> <div class="value"> - {{ parseInt(stats.music_duration) }} + {{ parseInt(stats.musicDuration) }} </div> <i18next tag="div" class="label" path="hours of music"/> </div> @@ -59,6 +59,7 @@ </template> <script> +import _ from 'lodash' import axios from 'axios' import logger from '@/logging' @@ -76,8 +77,16 @@ export default { var self = this this.isLoading = true logger.default.debug('Fetching instance stats...') - axios.get('instance/stats/').then((response) => { - self.stats = response.data + axios.get('instance/nodeinfo/2.0/').then((response) => { + let d = response.data + self.stats = {} + self.stats.users = _.get(d, 'usage.users.total') + self.stats.listenings = _.get(d, 'metadata.usage.listenings.total') + self.stats.trackFavorites = _.get(d, 'metadata.usage.favorites.tracks.total') + self.stats.musicDuration = _.get(d, 'metadata.library.music.hours') + self.stats.artists = _.get(d, 'metadata.library.artists.total') + self.stats.albums = _.get(d, 'metadata.library.albums.total') + self.stats.tracks = _.get(d, 'metadata.library.tracks.total') self.isLoading = false }) } diff --git a/front/src/store/instance.js b/front/src/store/instance.js index 245acaf039adb4cc92e5df0760c3b38733e6b241..e78e804898c8c02a1f237297d3ab3dc653e62c0e 100644 --- a/front/src/store/instance.js +++ b/front/src/store/instance.js @@ -24,6 +24,11 @@ export default { value: true } }, + subsonic: { + enabled: { + value: true + } + }, raven: { front_enabled: { value: false