Merge branch 'release/0.12'
This commit is contained in:
commit
107cca7ba3
150
CHANGELOG
150
CHANGELOG
|
@ -10,7 +10,155 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.
|
||||||
|
|
||||||
.. towncrier
|
.. 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
|
Upgrade instructions are available at https://docs.funkwhale.audio/upgrading.html
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
from rest_framework import routers
|
from rest_framework import routers
|
||||||
|
from rest_framework.urlpatterns import format_suffix_patterns
|
||||||
from django.conf.urls import include, url
|
from django.conf.urls import include, url
|
||||||
from funkwhale_api.activity import views as activity_views
|
from funkwhale_api.activity import views as activity_views
|
||||||
from funkwhale_api.instance import views as instance_views
|
from funkwhale_api.instance import views as instance_views
|
||||||
from funkwhale_api.music import views
|
from funkwhale_api.music import views
|
||||||
from funkwhale_api.playlists import views as playlists_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 rest_framework_jwt import views as jwt_views
|
||||||
|
|
||||||
from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet
|
from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet
|
||||||
|
@ -27,6 +29,10 @@ router.register(
|
||||||
'playlist-tracks')
|
'playlist-tracks')
|
||||||
v1_patterns = router.urls
|
v1_patterns = router.urls
|
||||||
|
|
||||||
|
subsonic_router = routers.SimpleRouter(trailing_slash=False)
|
||||||
|
subsonic_router.register(r'subsonic/rest', SubsonicViewSet, base_name='subsonic')
|
||||||
|
|
||||||
|
|
||||||
v1_patterns += [
|
v1_patterns += [
|
||||||
url(r'^instance/',
|
url(r'^instance/',
|
||||||
include(
|
include(
|
||||||
|
@ -68,4 +74,4 @@ v1_patterns += [
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^v1/', include((v1_patterns, 'v1'), namespace='v1'))
|
url(r'^v1/', include((v1_patterns, 'v1'), namespace='v1'))
|
||||||
]
|
] + format_suffix_patterns(subsonic_router.urls, allowed=['view'])
|
||||||
|
|
|
@ -133,6 +133,7 @@ LOCAL_APPS = (
|
||||||
'funkwhale_api.providers.audiofile',
|
'funkwhale_api.providers.audiofile',
|
||||||
'funkwhale_api.providers.youtube',
|
'funkwhale_api.providers.youtube',
|
||||||
'funkwhale_api.providers.acoustid',
|
'funkwhale_api.providers.acoustid',
|
||||||
|
'funkwhale_api.subsonic',
|
||||||
)
|
)
|
||||||
|
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||||
|
|
|
@ -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 = User.objects.create(email='demo@demo.com', username='demo', is_staff=True)
|
||||||
u.set_password('demo')
|
u.set_password('demo')
|
||||||
|
u.subsonic_api_token = 'demo'
|
||||||
u.save()
|
u.save()
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- 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('.')])
|
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
|
||||||
|
|
|
@ -85,13 +85,31 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||||
return response.Response({}, status=200)
|
return response.Response({}, status=200)
|
||||||
|
|
||||||
|
|
||||||
class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
|
class WellKnownViewSet(viewsets.GenericViewSet):
|
||||||
authentication_classes = []
|
authentication_classes = []
|
||||||
permission_classes = []
|
permission_classes = []
|
||||||
renderer_classes = [renderers.WebfingerRenderer]
|
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'])
|
@list_route(methods=['get'])
|
||||||
def webfinger(self, request, *args, **kwargs):
|
def webfinger(self, request, *args, **kwargs):
|
||||||
|
if not preferences.get('federation__enabled'):
|
||||||
|
return HttpResponse(status=405)
|
||||||
try:
|
try:
|
||||||
resource_type, resource = webfinger.clean_resource(
|
resource_type, resource = webfinger.clean_resource(
|
||||||
request.GET['resource'])
|
request.GET['resource'])
|
||||||
|
|
|
@ -68,3 +68,31 @@ class RavenEnabled(types.BooleanPreference):
|
||||||
'Wether error reporting to a Sentry instance using raven is enabled'
|
'Wether error reporting to a Sentry instance using raven is enabled'
|
||||||
' for front-end errors'
|
' 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.'
|
||||||
|
)
|
||||||
|
|
|
@ -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
|
|
@ -1,11 +1,9 @@
|
||||||
from django.conf.urls import url
|
from django.conf.urls import url
|
||||||
from django.views.decorators.cache import cache_page
|
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
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'^settings/$', views.InstanceSettings.as_view(), name='settings'),
|
||||||
url(r'^stats/$',
|
|
||||||
cache_page(60 * 5)(views.InstanceStats.as_view()), name='stats'),
|
|
||||||
]
|
]
|
||||||
|
|
|
@ -4,9 +4,17 @@ from rest_framework.response import Response
|
||||||
from dynamic_preferences.api import serializers
|
from dynamic_preferences.api import serializers
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
|
|
||||||
|
from funkwhale_api.common import preferences
|
||||||
|
|
||||||
|
from . import nodeinfo
|
||||||
from . import stats
|
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):
|
class InstanceSettings(views.APIView):
|
||||||
permission_classes = []
|
permission_classes = []
|
||||||
authentication_classes = []
|
authentication_classes = []
|
||||||
|
@ -27,10 +35,13 @@ class InstanceSettings(views.APIView):
|
||||||
return Response(data, status=200)
|
return Response(data, status=200)
|
||||||
|
|
||||||
|
|
||||||
class InstanceStats(views.APIView):
|
class NodeInfo(views.APIView):
|
||||||
permission_classes = []
|
permission_classes = []
|
||||||
authentication_classes = []
|
authentication_classes = []
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
data = stats.get()
|
if not preferences.get('instance__nodeinfo_enabled'):
|
||||||
return Response(data, status=200)
|
return Response(status=404)
|
||||||
|
data = nodeinfo.get()
|
||||||
|
return Response(
|
||||||
|
data, status=200, content_type=NODEINFO_2_CONTENT_TYPE)
|
||||||
|
|
|
@ -26,7 +26,7 @@ class ArtistFactory(factory.django.DjangoModelFactory):
|
||||||
class AlbumFactory(factory.django.DjangoModelFactory):
|
class AlbumFactory(factory.django.DjangoModelFactory):
|
||||||
title = factory.Faker('sentence', nb_words=3)
|
title = factory.Faker('sentence', nb_words=3)
|
||||||
mbid = factory.Faker('uuid4')
|
mbid = factory.Faker('uuid4')
|
||||||
release_date = factory.Faker('date')
|
release_date = factory.Faker('date_object')
|
||||||
cover = factory.django.ImageField()
|
cover = factory.django.ImageField()
|
||||||
artist = factory.SubFactory(ArtistFactory)
|
artist = factory.SubFactory(ArtistFactory)
|
||||||
release_group_id = factory.Faker('uuid4')
|
release_group_id = factory.Faker('uuid4')
|
||||||
|
|
|
@ -76,6 +76,11 @@ class APIModelMixin(models.Model):
|
||||||
self.musicbrainz_model, self.mbid)
|
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):
|
class Artist(APIModelMixin):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
@ -89,6 +94,7 @@ class Artist(APIModelMixin):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
api = musicbrainz.api.artists
|
api = musicbrainz.api.artists
|
||||||
|
objects = ArtistQuerySet.as_manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
@ -106,7 +112,7 @@ class Artist(APIModelMixin):
|
||||||
kwargs.update({'name': name})
|
kwargs.update({'name': name})
|
||||||
return cls.objects.get_or_create(
|
return cls.objects.get_or_create(
|
||||||
name__iexact=name,
|
name__iexact=name,
|
||||||
defaults=kwargs)[0]
|
defaults=kwargs)
|
||||||
|
|
||||||
|
|
||||||
def import_artist(v):
|
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)
|
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):
|
class Album(APIModelMixin):
|
||||||
title = models.CharField(max_length=255)
|
title = models.CharField(max_length=255)
|
||||||
artist = models.ForeignKey(
|
artist = models.ForeignKey(
|
||||||
|
@ -173,6 +184,7 @@ class Album(APIModelMixin):
|
||||||
'converter': import_artist,
|
'converter': import_artist,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
objects = AlbumQuerySet.as_manager()
|
||||||
|
|
||||||
def get_image(self):
|
def get_image(self):
|
||||||
image_data = musicbrainz.api.images.get_front(str(self.mbid))
|
image_data = musicbrainz.api.images.get_front(str(self.mbid))
|
||||||
|
@ -196,7 +208,7 @@ class Album(APIModelMixin):
|
||||||
kwargs.update({'title': title})
|
kwargs.update({'title': title})
|
||||||
return cls.objects.get_or_create(
|
return cls.objects.get_or_create(
|
||||||
title__iexact=title,
|
title__iexact=title,
|
||||||
defaults=kwargs)[0]
|
defaults=kwargs)
|
||||||
|
|
||||||
|
|
||||||
def import_tags(instance, cleaned_data, raw_data):
|
def import_tags(instance, cleaned_data, raw_data):
|
||||||
|
@ -403,7 +415,7 @@ class Track(APIModelMixin):
|
||||||
kwargs.update({'title': title})
|
kwargs.update({'title': title})
|
||||||
return cls.objects.get_or_create(
|
return cls.objects.get_or_create(
|
||||||
title__iexact=title,
|
title__iexact=title,
|
||||||
defaults=kwargs)[0]
|
defaults=kwargs)
|
||||||
|
|
||||||
|
|
||||||
class TrackFile(models.Model):
|
class TrackFile(models.Model):
|
||||||
|
@ -457,7 +469,13 @@ class TrackFile(models.Model):
|
||||||
def filename(self):
|
def filename(self):
|
||||||
return '{}{}'.format(
|
return '{}{}'.format(
|
||||||
self.track.full_name,
|
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):
|
def save(self, **kwargs):
|
||||||
if not self.mimetype and self.audio_file:
|
if not self.mimetype and self.audio_file:
|
||||||
|
|
|
@ -39,7 +39,7 @@ def import_track_from_remote(library_track):
|
||||||
except (KeyError, AssertionError):
|
except (KeyError, AssertionError):
|
||||||
pass
|
pass
|
||||||
else:
|
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:
|
try:
|
||||||
album_mbid = metadata['release']['musicbrainz_id']
|
album_mbid = metadata['release']['musicbrainz_id']
|
||||||
|
@ -47,9 +47,9 @@ def import_track_from_remote(library_track):
|
||||||
except (KeyError, AssertionError):
|
except (KeyError, AssertionError):
|
||||||
pass
|
pass
|
||||||
else:
|
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(
|
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:
|
try:
|
||||||
artist_mbid = metadata['artist']['musicbrainz_id']
|
artist_mbid = metadata['artist']['musicbrainz_id']
|
||||||
|
@ -57,20 +57,20 @@ def import_track_from_remote(library_track):
|
||||||
except (KeyError, AssertionError):
|
except (KeyError, AssertionError):
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
artist = models.Artist.get_or_create_from_api(mbid=artist_mbid)
|
artist, _ = models.Artist.get_or_create_from_api(mbid=artist_mbid)
|
||||||
album = models.Album.get_or_create_from_title(
|
album, _ = models.Album.get_or_create_from_title(
|
||||||
library_track.album_title, artist=artist)
|
library_track.album_title, artist=artist)
|
||||||
return models.Track.get_or_create_from_title(
|
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
|
# worst case scenario, we have absolutely no way to link to a
|
||||||
# musicbrainz resource, we rely on the name/titles
|
# 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)
|
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)
|
library_track.album_title, artist=artist)
|
||||||
return models.Track.get_or_create_from_title(
|
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):
|
def _do_import(import_job, replace=False, use_acoustid=True):
|
||||||
|
|
|
@ -245,6 +245,53 @@ def get_file_path(audio_file):
|
||||||
return path
|
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):
|
class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
queryset = (models.TrackFile.objects.all().order_by('-id'))
|
queryset = (models.TrackFile.objects.all().order_by('-id'))
|
||||||
serializer_class = serializers.TrackFileSerializer
|
serializer_class = serializers.TrackFileSerializer
|
||||||
|
@ -261,54 +308,10 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
'track__artist',
|
'track__artist',
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
f = queryset.get(pk=kwargs['pk'])
|
return handle_serve(queryset.get(pk=kwargs['pk']))
|
||||||
except models.TrackFile.DoesNotExist:
|
except models.TrackFile.DoesNotExist:
|
||||||
return Response(status=404)
|
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'])
|
@list_route(methods=['get'])
|
||||||
def viewable(self, request, *args, **kwargs):
|
def viewable(self, request, *args, **kwargs):
|
||||||
return Response({}, status=200)
|
return Response({}, status=200)
|
||||||
|
|
|
@ -9,6 +9,12 @@ from funkwhale_api.common import fields
|
||||||
from funkwhale_api.common import preferences
|
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):
|
class Playlist(models.Model):
|
||||||
name = models.CharField(max_length=50)
|
name = models.CharField(max_length=50)
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
|
@ -18,6 +24,8 @@ class Playlist(models.Model):
|
||||||
auto_now=True)
|
auto_now=True)
|
||||||
privacy_level = fields.get_privacy_field()
|
privacy_level = fields.get_privacy_field()
|
||||||
|
|
||||||
|
objects = PlaylistQuerySet.as_manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
@ -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.'
|
||||||
|
)
|
|
@ -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])
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -9,6 +9,7 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||||
username = factory.Sequence(lambda n: 'user-{0}'.format(n))
|
username = factory.Sequence(lambda n: 'user-{0}'.format(n))
|
||||||
email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n))
|
email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n))
|
||||||
password = factory.PostGenerationMethodCall('set_password', 'test')
|
password = factory.PostGenerationMethodCall('set_password', 'test')
|
||||||
|
subsonic_api_token = None
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = 'users.User'
|
model = 'users.User'
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -2,6 +2,7 @@
|
||||||
from __future__ import unicode_literals, absolute_import
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import secrets
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
@ -38,6 +39,13 @@ class User(AbstractUser):
|
||||||
|
|
||||||
privacy_level = fields.get_privacy_field()
|
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):
|
def __str__(self):
|
||||||
return self.username
|
return self.username
|
||||||
|
@ -49,9 +57,15 @@ class User(AbstractUser):
|
||||||
self.secret_key = uuid.uuid4()
|
self.secret_key = uuid.uuid4()
|
||||||
return self.secret_key
|
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):
|
def set_password(self, raw_password):
|
||||||
super().set_password(raw_password)
|
super().set_password(raw_password)
|
||||||
self.update_secret_key()
|
self.update_secret_key()
|
||||||
|
if self.subsonic_api_token:
|
||||||
|
self.update_subsonic_api_token()
|
||||||
|
|
||||||
def get_activity_url(self):
|
def get_activity_url(self):
|
||||||
return settings.FUNKWHALE_URL + '/@{}'.format(self.username)
|
return settings.FUNKWHALE_URL + '/@{}'.format(self.username)
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import mixins
|
from rest_framework import mixins
|
||||||
from rest_framework import viewsets
|
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 rest_auth.registration.views import RegisterView as BaseRegisterView
|
||||||
from allauth.account.adapter import get_adapter
|
from allauth.account.adapter import get_adapter
|
||||||
|
|
||||||
|
from funkwhale_api.common import preferences
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
|
@ -37,6 +39,28 @@ class UserViewSet(
|
||||||
serializer = serializers.UserReadSerializer(request.user)
|
serializer = serializers.UserReadSerializer(request.user)
|
||||||
return Response(serializer.data)
|
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):
|
def update(self, request, *args, **kwargs):
|
||||||
if not self.request.user.username == kwargs.get('username'):
|
if not self.request.user.username == kwargs.get('username'):
|
||||||
return Response(status=403)
|
return Response(status=403)
|
||||||
|
|
|
@ -130,6 +130,7 @@ def logged_in_api_client(db, factories, api_client):
|
||||||
"""
|
"""
|
||||||
user = factories['users.User']()
|
user = factories['users.User']()
|
||||||
assert api_client.login(username=user.username, password='test')
|
assert api_client.login(username=user.username, password='test')
|
||||||
|
api_client.force_authenticate(user=user)
|
||||||
setattr(api_client, 'user', user)
|
setattr(api_client, 'user', user)
|
||||||
yield api_client
|
yield api_client
|
||||||
delattr(api_client, 'user')
|
delattr(api_client, 'user')
|
||||||
|
|
|
@ -70,6 +70,32 @@ def test_wellknown_webfinger_system(
|
||||||
assert response.data == serializer.data
|
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(
|
def test_audio_file_list_requires_authenticated_actor(
|
||||||
db, preferences, api_client):
|
db, preferences, api_client):
|
||||||
preferences['federation__music_needs_approval'] = True
|
preferences['federation__music_needs_approval'] = True
|
||||||
|
|
|
@ -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
|
|
@ -3,16 +3,6 @@ from django.urls import reverse
|
||||||
from funkwhale_api.instance import stats
|
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):
|
def test_get_users(mocker):
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
'funkwhale_api.users.models.User.objects.count', return_value=42)
|
'funkwhale_api.users.models.User.objects.count', return_value=42)
|
||||||
|
|
|
@ -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
|
|
@ -66,7 +66,7 @@ def test_import_job_from_federation_musicbrainz_recording(factories, mocker):
|
||||||
t = factories['music.Track']()
|
t = factories['music.Track']()
|
||||||
track_from_api = mocker.patch(
|
track_from_api = mocker.patch(
|
||||||
'funkwhale_api.music.models.Track.get_or_create_from_api',
|
'funkwhale_api.music.models.Track.get_or_create_from_api',
|
||||||
return_value=t)
|
return_value=(t, True))
|
||||||
lt = factories['federation.LibraryTrack'](
|
lt = factories['federation.LibraryTrack'](
|
||||||
metadata__recording__musicbrainz=True,
|
metadata__recording__musicbrainz=True,
|
||||||
artist_name='Hello',
|
artist_name='Hello',
|
||||||
|
@ -92,7 +92,7 @@ def test_import_job_from_federation_musicbrainz_release(factories, mocker):
|
||||||
a = factories['music.Album']()
|
a = factories['music.Album']()
|
||||||
album_from_api = mocker.patch(
|
album_from_api = mocker.patch(
|
||||||
'funkwhale_api.music.models.Album.get_or_create_from_api',
|
'funkwhale_api.music.models.Album.get_or_create_from_api',
|
||||||
return_value=a)
|
return_value=(a, True))
|
||||||
lt = factories['federation.LibraryTrack'](
|
lt = factories['federation.LibraryTrack'](
|
||||||
metadata__release__musicbrainz=True,
|
metadata__release__musicbrainz=True,
|
||||||
artist_name='Hello',
|
artist_name='Hello',
|
||||||
|
@ -121,7 +121,7 @@ def test_import_job_from_federation_musicbrainz_artist(factories, mocker):
|
||||||
a = factories['music.Artist']()
|
a = factories['music.Artist']()
|
||||||
artist_from_api = mocker.patch(
|
artist_from_api = mocker.patch(
|
||||||
'funkwhale_api.music.models.Artist.get_or_create_from_api',
|
'funkwhale_api.music.models.Artist.get_or_create_from_api',
|
||||||
return_value=a)
|
return_value=(a, True))
|
||||||
lt = factories['federation.LibraryTrack'](
|
lt = factories['federation.LibraryTrack'](
|
||||||
metadata__artist__musicbrainz=True,
|
metadata__artist__musicbrainz=True,
|
||||||
album_title='World',
|
album_title='World',
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -2,3 +2,17 @@
|
||||||
def test__str__(factories):
|
def test__str__(factories):
|
||||||
user = factories['users.User'](username='hello')
|
user = factories['users.User'](username='hello')
|
||||||
assert user.__str__() == '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'
|
||||||
|
|
|
@ -167,6 +167,77 @@ def test_user_can_patch_his_own_settings(logged_in_api_client):
|
||||||
assert user.privacy_level == 'me'
|
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'])
|
@pytest.mark.parametrize('method', ['put', 'patch'])
|
||||||
def test_user_cannot_patch_another_user(
|
def test_user_cannot_patch_another_user(
|
||||||
method, logged_in_api_client, factories):
|
method, logged_in_api_client, factories):
|
||||||
|
|
|
@ -5,7 +5,7 @@ demo_path="/srv/funkwhale-demo/demo"
|
||||||
|
|
||||||
echo 'Cleaning everything...'
|
echo 'Cleaning everything...'
|
||||||
cd $demo_path
|
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/*
|
rm -rf /srv/funkwhale-demo/demo/*
|
||||||
mkdir -p $demo_path
|
mkdir -p $demo_path
|
||||||
echo 'Downloading demo files...'
|
echo 'Downloading demo files...'
|
||||||
|
@ -23,9 +23,10 @@ echo "DJANGO_SECRET_KEY=demo" >> .env
|
||||||
echo "DJANGO_ALLOWED_HOSTS=demo.funkwhale.audio" >> .env
|
echo "DJANGO_ALLOWED_HOSTS=demo.funkwhale.audio" >> .env
|
||||||
echo "FUNKWHALE_VERSION=$version" >> .env
|
echo "FUNKWHALE_VERSION=$version" >> .env
|
||||||
echo "FUNKWHALE_API_PORT=5001" >> .env
|
echo "FUNKWHALE_API_PORT=5001" >> .env
|
||||||
|
echo "FEDERATION_MUSIC_NEEDS_APPROVAL=False" >>.env
|
||||||
docker-compose pull
|
echo "PROTECT_AUDIO_FILES=False" >> .env
|
||||||
docker-compose up -d postgres redis
|
/usr/local/bin/docker-compose pull
|
||||||
|
/usr/local/bin/docker-compose up -d postgres redis
|
||||||
sleep 5
|
sleep 5
|
||||||
docker-compose run --rm api demo/load-demo-data.sh
|
/usr/local/bin/docker-compose run --rm api demo/load-demo-data.sh
|
||||||
docker-compose up -d
|
/usr/local/bin/docker-compose up -d
|
||||||
|
|
|
@ -84,9 +84,9 @@ Define MUSIC_DIRECTORY_PATH /srv/funkwhale/data/music
|
||||||
ProxyPassReverse ${funkwhale-api}/federation
|
ProxyPassReverse ${funkwhale-api}/federation
|
||||||
</Location>
|
</Location>
|
||||||
|
|
||||||
<Location "/.well-known/webfinger">
|
<Location "/.well-known/">
|
||||||
ProxyPass ${funkwhale-api}/.well-known/webfinger
|
ProxyPass ${funkwhale-api}/.well-known/
|
||||||
ProxyPassReverse ${funkwhale-api}/.well-known/webfinger
|
ProxyPassReverse ${funkwhale-api}/.well-known/
|
||||||
</Location>
|
</Location>
|
||||||
|
|
||||||
Alias /media /srv/funkwhale/data/media
|
Alias /media /srv/funkwhale/data/media
|
||||||
|
|
|
@ -48,9 +48,9 @@ FUNKWHALE_URL=https://yourdomain.funwhale
|
||||||
# EMAIL_CONFIG=consolemail:// # output emails to console (the default)
|
# EMAIL_CONFIG=consolemail:// # output emails to console (the default)
|
||||||
# EMAIL_CONFIG=dummymail:// # disable email sending completely
|
# EMAIL_CONFIG=dummymail:// # disable email sending completely
|
||||||
# On a production instance, you'll usually want to use an external SMTP server:
|
# 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://user@:password@youremail.host:25
|
||||||
# EMAIL_CONFIG=smtp+ssl://user@:password@youremail.host:465'
|
# EMAIL_CONFIG=smtp+ssl://user@:password@youremail.host:465
|
||||||
# EMAIL_CONFIG=smtp+tls://user@:password@youremail.host:587'
|
# EMAIL_CONFIG=smtp+tls://user@:password@youremail.host:587
|
||||||
|
|
||||||
# The email address to use to send systme emails. By default, we will
|
# The email address to use to send systme emails. By default, we will
|
||||||
# DEFAULT_FROM_EMAIL=noreply@yourdomain
|
# DEFAULT_FROM_EMAIL=noreply@yourdomain
|
||||||
|
|
|
@ -67,9 +67,9 @@ server {
|
||||||
proxy_pass http://funkwhale-api/federation/;
|
proxy_pass http://funkwhale-api/federation/;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /.well-known/webfinger {
|
location /.well-known/ {
|
||||||
include /etc/nginx/funkwhale_proxy.conf;
|
include /etc/nginx/funkwhale_proxy.conf;
|
||||||
proxy_pass http://funkwhale-api/.well-known/webfinger;
|
proxy_pass http://funkwhale-api/.well-known/;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /media/ {
|
location /media/ {
|
||||||
|
|
2
dev.yml
2
dev.yml
|
@ -20,7 +20,7 @@ services:
|
||||||
- internal
|
- internal
|
||||||
labels:
|
labels:
|
||||||
traefik.backend: "${COMPOSE_PROJECT_NAME-node1}"
|
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.enable: 'true'
|
||||||
traefik.federation.protocol: 'http'
|
traefik.federation.protocol: 'http'
|
||||||
traefik.federation.port: "${WEBPACK_DEVSERVER_PORT-8080}"
|
traefik.federation.port: "${WEBPACK_DEVSERVER_PORT-8080}"
|
||||||
|
|
|
@ -82,5 +82,9 @@ http {
|
||||||
include /etc/nginx/funkwhale_proxy.conf;
|
include /etc/nginx/funkwhale_proxy.conf;
|
||||||
proxy_pass http://api:12081/;
|
proxy_pass http://api:12081/;
|
||||||
}
|
}
|
||||||
|
location /rest/ {
|
||||||
|
include /etc/nginx/funkwhale_proxy.conf;
|
||||||
|
proxy_pass http://api:12081/api/subsonic/rest/;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
defaultEntryPoints = ["http", "https"]
|
defaultEntryPoints = ["http", "https"]
|
||||||
|
[accessLog]
|
||||||
################################################################
|
################################################################
|
||||||
# Web configuration backend
|
# Web configuration backend
|
||||||
################################################################
|
################################################################
|
||||||
|
|
|
@ -11,6 +11,7 @@ Funkwhale is a self-hosted, modern free and open-source music server, heavily in
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
|
users/index
|
||||||
features
|
features
|
||||||
installation/index
|
installation/index
|
||||||
configuration
|
configuration
|
||||||
|
|
|
@ -77,7 +77,8 @@ Frontend setup
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
You do not need to do this if you are deploying using Docker, as frontend files
|
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:
|
Files for the web frontend are purely static and can simply be downloaded, unzipped and served from any webserver:
|
||||||
|
|
||||||
|
|
|
@ -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.
|
similarly from version to version, but some of them may require additional steps.
|
||||||
Those steps would be described in the version release notes.
|
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
|
Docker setup
|
||||||
^^^^^^^^^^^^
|
------------
|
||||||
|
|
||||||
If you've followed the setup instructions in :doc:`Docker`, upgrade path is
|
If you've followed the setup instructions in :doc:`Docker`, upgrade path is
|
||||||
easy:
|
easy:
|
||||||
|
@ -57,10 +37,33 @@ easy:
|
||||||
# Relaunch the containers
|
# Relaunch the containers
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
Non-docker setup
|
|
||||||
^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
On non docker-setup, upgrade involves a few more commands. We assume your setup
|
|
||||||
|
Non-docker setup
|
||||||
|
----------------
|
||||||
|
|
||||||
|
Upgrade the static files
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
On non-docker setups, the front-end app
|
||||||
|
is updated separately from the API. This is as simple as downloading
|
||||||
|
the zip with the static files and extracting it in the correct place.
|
||||||
|
|
||||||
|
The following example assume your setup match :ref:`frontend-setup`.
|
||||||
|
|
||||||
|
.. parsed-literal::
|
||||||
|
|
||||||
|
# this assumes you want to upgrade to version "|version|"
|
||||||
|
export FUNKWHALE_VERSION="|version|"
|
||||||
|
cd /srv/funkwhale
|
||||||
|
curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front"
|
||||||
|
unzip -o front.zip
|
||||||
|
rm front.zip
|
||||||
|
|
||||||
|
Upgrading the API
|
||||||
|
^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
On non-docker, upgrade involves a few more commands. We assume your setup
|
||||||
match what is described in :doc:`debian`:
|
match what is described in :doc:`debian`:
|
||||||
|
|
||||||
.. parsed-literal::
|
.. parsed-literal::
|
||||||
|
|
|
@ -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.
|
|
@ -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
|
|
@ -34,7 +34,7 @@ module.exports = {
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true,
|
ws: true,
|
||||||
filter: function (pathname, req) {
|
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 => {
|
let matches = proxified.filter(e => {
|
||||||
return pathname.match(`^/${e}`)
|
return pathname.match(`^/${e}`)
|
||||||
})
|
})
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
<h1 class="ui huge header">
|
<h1 class="ui huge header">
|
||||||
{{ $t('Welcome on Funkwhale') }}
|
{{ $t('Welcome on Funkwhale') }}
|
||||||
</h1>
|
</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">
|
<router-link class="ui icon button" to="/about">
|
||||||
<i class="info icon"></i>
|
<i class="info icon"></i>
|
||||||
{{ $t('Learn more about this instance') }}
|
{{ $t('Learn more about this instance') }}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<div :class="['ui', {'tiny': discrete}, 'buttons']">
|
<div :class="['ui', {'tiny': discrete}, 'buttons']">
|
||||||
<button
|
<button
|
||||||
:title="$t('Add to current queue')"
|
:title="$t('Add to current queue')"
|
||||||
@click="add"
|
@click="addNext(true)"
|
||||||
:class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']">
|
:class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']">
|
||||||
<i class="ui play icon"></i>
|
<i class="ui play icon"></i>
|
||||||
<template v-if="!discrete"><slot><i18next path="Play"/></slot></template>
|
<template v-if="!discrete"><slot><i18next path="Play"/></slot></template>
|
||||||
|
@ -42,9 +42,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
if (!this.discrete) {
|
jQuery(this.$el).find('.ui.dropdown').dropdown()
|
||||||
jQuery(this.$el).find('.ui.dropdown').dropdown()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
playable () {
|
playable () {
|
||||||
|
@ -98,9 +96,11 @@ export default {
|
||||||
addNext (next) {
|
addNext (next) {
|
||||||
let self = this
|
let self = this
|
||||||
this.triggerLoad()
|
this.triggerLoad()
|
||||||
|
let wasEmpty = this.$store.state.queue.tracks.length === 0
|
||||||
this.getPlayableTracks().then((tracks) => {
|
this.getPlayableTracks().then((tracks) => {
|
||||||
self.$store.dispatch('queue/appendMany', {tracks: tracks, index: self.$store.state.queue.currentIndex + 1})
|
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')
|
self.$store.dispatch('queue/next')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -26,6 +26,10 @@
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
<div class="ui small text container">
|
<div class="ui small text container">
|
||||||
<h2 class="ui header"><i18next path="Change my password"/></h2>
|
<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()">
|
<form class="ui form" @submit.prevent="submitPassword()">
|
||||||
<div v-if="passwordError" class="ui negative message">
|
<div v-if="passwordError" class="ui negative message">
|
||||||
<div class="header"><i18next path="Cannot change your password"/></div>
|
<div class="header"><i18next path="Cannot change your password"/></div>
|
||||||
|
@ -41,10 +45,25 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label><i18next path="New password"/></label>
|
<label><i18next path="New password"/></label>
|
||||||
<password-input required v-model="new_password" />
|
<password-input required v-model="new_password" />
|
||||||
|
|
||||||
</div>
|
</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>
|
</form>
|
||||||
|
<div class="ui hidden divider" />
|
||||||
|
<subsonic-token-form />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -55,10 +74,12 @@ import $ from 'jquery'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import logger from '@/logging'
|
import logger from '@/logging'
|
||||||
import PasswordInput from '@/components/forms/PasswordInput'
|
import PasswordInput from '@/components/forms/PasswordInput'
|
||||||
|
import SubsonicTokenForm from '@/components/auth/SubsonicTokenForm'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
PasswordInput
|
PasswordInput,
|
||||||
|
SubsonicTokenForm
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
let d = {
|
let d = {
|
||||||
|
|
|
@ -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>
|
|
@ -3,7 +3,7 @@
|
||||||
<div v-if="stats" class="ui stackable two column grid">
|
<div v-if="stats" class="ui stackable two column grid">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<h3 class="ui left aligned header"><i18next path="User activity"/></h3>
|
<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="statistic">
|
||||||
<div class="value">
|
<div class="value">
|
||||||
<i class="green user icon"></i>
|
<i class="green user icon"></i>
|
||||||
|
@ -19,7 +19,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="statistic">
|
<div class="statistic">
|
||||||
<div class="value">
|
<div class="value">
|
||||||
<i class="pink heart icon"></i> {{ stats.track_favorites }}
|
<i class="pink heart icon"></i> {{ stats.trackFavorites }}
|
||||||
</div>
|
</div>
|
||||||
<i18next tag="div" class="label" path="Tracks favorited"/>
|
<i18next tag="div" class="label" path="Tracks favorited"/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -30,7 +30,7 @@
|
||||||
<div class="ui mini horizontal statistics">
|
<div class="ui mini horizontal statistics">
|
||||||
<div class="statistic">
|
<div class="statistic">
|
||||||
<div class="value">
|
<div class="value">
|
||||||
{{ parseInt(stats.music_duration) }}
|
{{ parseInt(stats.musicDuration) }}
|
||||||
</div>
|
</div>
|
||||||
<i18next tag="div" class="label" path="hours of music"/>
|
<i18next tag="div" class="label" path="hours of music"/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -59,6 +59,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import _ from 'lodash'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import logger from '@/logging'
|
import logger from '@/logging'
|
||||||
|
|
||||||
|
@ -76,8 +77,16 @@ export default {
|
||||||
var self = this
|
var self = this
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
logger.default.debug('Fetching instance stats...')
|
logger.default.debug('Fetching instance stats...')
|
||||||
axios.get('instance/stats/').then((response) => {
|
axios.get('instance/nodeinfo/2.0/').then((response) => {
|
||||||
self.stats = response.data
|
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
|
self.isLoading = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,11 @@ export default {
|
||||||
value: true
|
value: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
subsonic: {
|
||||||
|
enabled: {
|
||||||
|
value: true
|
||||||
|
}
|
||||||
|
},
|
||||||
raven: {
|
raven: {
|
||||||
front_enabled: {
|
front_enabled: {
|
||||||
value: false
|
value: false
|
||||||
|
|
Loading…
Reference in New Issue