Merge branch 'release/0.12'

This commit is contained in:
Eliot Berriot 2018-05-09 23:46:22 +02:00
commit 107cca7ba3
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
58 changed files with 2625 additions and 139 deletions

150
CHANGELOG
View File

@ -10,7 +10,155 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.
.. towncrier
0.11 (unreleased)
0.12 (2018-05-09)
-----------------
Upgrade instructions are available at
https://docs.funkwhale.audio/upgrading.html
Features:
- Subsonic API implementation to offer compatibility with existing clients such
as DSub (#75)
- Use nodeinfo standard for publishing instance information (#192)
Enhancements:
- Play button now play tracks immediately instead of appending them to the
queue (#99, #156)
Bugfixes:
- Fix broken federated import (#193)
Documentation:
- Up-to-date documentation for upgrading front-end files on docker setup (#132)
Subsonic API
^^^^^^^^^^^^
This release implements some core parts of the Subsonic API, which is widely
deployed in various projects and supported by numerous clients.
By offering this API in Funkwhale, we make it possible to access the instance
library and listen to the music without from existing Subsonic clients, and
without developping our own alternative clients for each and every platform.
Most advanced Subsonic clients support offline caching of music files,
playlist management and search, which makes them well-suited for nomadic use.
Please head over :doc:`users/apps` for more informations about supported clients
and user instructions.
At the instance-level, the Subsonic API is enabled by default, but require
and additional endpoint to be added in you reverse-proxy configuration.
On nginx, add the following block::
location /rest/ {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-api/api/subsonic/rest/;
}
On Apache, add the following block::
<Location "/rest">
ProxyPass ${funkwhale-api}/api/subsonic/rest
ProxyPassReverse ${funkwhale-api}/api/subsonic/rest
</Location>
The Subsonic can be disabled at the instance level from the django admin.
.. note::
Because of Subsonic's API design which assumes cleartext storing of
user passwords, we chose to have a dedicated, separate password
for that purpose. Users can generate this password from their
settings page in the web client.
Nodeinfo standard for instance information and stats
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. warning::
The ``/api/v1/instance/stats/`` endpoint which was used to display
instance data in the about page is removed in favor of the new
``/api/v1/instance/nodeinfo/2.0/`` endpoint.
In earlier version, we where using a custom endpoint and format for
our instance information and statistics. While this was working,
this was not compatible with anything else on the fediverse.
We now offer a nodeinfo 2.0 endpoint which provides, in a single place,
all the instance information such as library and user activity statistics,
public instance settings (description, registration and federation status, etc.).
We offer two settings to manage nodeinfo in your Funkwhale instance:
1. One setting to completely disable nodeinfo, but this is not recommended
as the exposed data may be needed to make some parts of the front-end
work (especially the about page).
2. One setting to disable only usage and library statistics in the nodeinfo
endpoint. This is useful if you want the nodeinfo endpoint to work,
but don't feel comfortable sharing aggregated statistics about your library
and user activity.
To make your instance fully compatible with the nodeinfo protocol, you need to
to edit your nginx configuration file:
.. code-block::
# before
...
location /.well-known/webfinger {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-api/.well-known/webfinger;
}
...
# after
...
location /.well-known/ {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-api/.well-known/;
}
...
You can do the same if you use apache:
.. code-block::
# before
...
<Location "/.well-known/webfinger">
ProxyPass ${funkwhale-api}/.well-known/webfinger
ProxyPassReverse ${funkwhale-api}/.well-known/webfinger
</Location>
...
# after
...
<Location "/.well-known/">
ProxyPass ${funkwhale-api}/.well-known/
ProxyPassReverse ${funkwhale-api}/.well-known/
</Location>
...
This will ensure all well-known endpoints are proxied to funkwhale, and
not just webfinger one.
Links:
- About nodeinfo: https://github.com/jhass/nodeinfo
0.11 (2018-05-06)
-----------------
Upgrade instructions are available at https://docs.funkwhale.audio/upgrading.html

View File

@ -1,9 +1,11 @@
from rest_framework import routers
from rest_framework.urlpatterns import format_suffix_patterns
from django.conf.urls import include, url
from funkwhale_api.activity import views as activity_views
from funkwhale_api.instance import views as instance_views
from funkwhale_api.music import views
from funkwhale_api.playlists import views as playlists_views
from funkwhale_api.subsonic.views import SubsonicViewSet
from rest_framework_jwt import views as jwt_views
from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet
@ -27,6 +29,10 @@ router.register(
'playlist-tracks')
v1_patterns = router.urls
subsonic_router = routers.SimpleRouter(trailing_slash=False)
subsonic_router.register(r'subsonic/rest', SubsonicViewSet, base_name='subsonic')
v1_patterns += [
url(r'^instance/',
include(
@ -68,4 +74,4 @@ v1_patterns += [
urlpatterns = [
url(r'^v1/', include((v1_patterns, 'v1'), namespace='v1'))
]
] + format_suffix_patterns(subsonic_router.urls, allowed=['view'])

View File

@ -133,6 +133,7 @@ LOCAL_APPS = (
'funkwhale_api.providers.audiofile',
'funkwhale_api.providers.youtube',
'funkwhale_api.providers.acoustid',
'funkwhale_api.subsonic',
)
# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps

View File

@ -3,4 +3,5 @@ from funkwhale_api.users.models import User
u = User.objects.create(email='demo@demo.com', username='demo', is_staff=True)
u.set_password('demo')
u.subsonic_api_token = 'demo'
u.save()

View File

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
__version__ = '0.11'
__version__ = '0.12'
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])

View File

@ -85,13 +85,31 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
return response.Response({}, status=200)
class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
class WellKnownViewSet(viewsets.GenericViewSet):
authentication_classes = []
permission_classes = []
renderer_classes = [renderers.WebfingerRenderer]
@list_route(methods=['get'])
def nodeinfo(self, request, *args, **kwargs):
if not preferences.get('instance__nodeinfo_enabled'):
return HttpResponse(status=404)
data = {
'links': [
{
'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0',
'href': utils.full_url(
reverse('api:v1:instance:nodeinfo-2.0')
)
}
]
}
return response.Response(data)
@list_route(methods=['get'])
def webfinger(self, request, *args, **kwargs):
if not preferences.get('federation__enabled'):
return HttpResponse(status=405)
try:
resource_type, resource = webfinger.clean_resource(
request.GET['resource'])

View File

@ -68,3 +68,31 @@ class RavenEnabled(types.BooleanPreference):
'Wether error reporting to a Sentry instance using raven is enabled'
' for front-end errors'
)
@global_preferences_registry.register
class InstanceNodeinfoEnabled(types.BooleanPreference):
show_in_api = False
section = instance
name = 'nodeinfo_enabled'
default = True
verbose_name = 'Enable nodeinfo endpoint'
help_text = (
'This endpoint is needed for your about page to work.'
'It\'s also helpful for the various monitoring '
'tools that map and analyzize the fediverse, '
'but you can disable it completely if needed.'
)
@global_preferences_registry.register
class InstanceNodeinfoStatsEnabled(types.BooleanPreference):
show_in_api = False
section = instance
name = 'nodeinfo_stats_enabled'
default = True
verbose_name = 'Enable usage and library stats in nodeinfo endpoint'
help_text = (
'Disable this f you don\'t want to share usage and library statistics'
'in the nodeinfo endpoint but don\'t want to disable it completely.'
)

View File

@ -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

View File

@ -1,11 +1,9 @@
from django.conf.urls import url
from django.views.decorators.cache import cache_page
from . import views
urlpatterns = [
url(r'^nodeinfo/2.0/$', views.NodeInfo.as_view(), name='nodeinfo-2.0'),
url(r'^settings/$', views.InstanceSettings.as_view(), name='settings'),
url(r'^stats/$',
cache_page(60 * 5)(views.InstanceStats.as_view()), name='stats'),
]

View File

@ -4,9 +4,17 @@ from rest_framework.response import Response
from dynamic_preferences.api import serializers
from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.common import preferences
from . import nodeinfo
from . import stats
NODEINFO_2_CONTENT_TYPE = (
'application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8' # noqa
)
class InstanceSettings(views.APIView):
permission_classes = []
authentication_classes = []
@ -27,10 +35,13 @@ class InstanceSettings(views.APIView):
return Response(data, status=200)
class InstanceStats(views.APIView):
class NodeInfo(views.APIView):
permission_classes = []
authentication_classes = []
def get(self, request, *args, **kwargs):
data = stats.get()
return Response(data, status=200)
if not preferences.get('instance__nodeinfo_enabled'):
return Response(status=404)
data = nodeinfo.get()
return Response(
data, status=200, content_type=NODEINFO_2_CONTENT_TYPE)

View File

@ -26,7 +26,7 @@ class ArtistFactory(factory.django.DjangoModelFactory):
class AlbumFactory(factory.django.DjangoModelFactory):
title = factory.Faker('sentence', nb_words=3)
mbid = factory.Faker('uuid4')
release_date = factory.Faker('date')
release_date = factory.Faker('date_object')
cover = factory.django.ImageField()
artist = factory.SubFactory(ArtistFactory)
release_group_id = factory.Faker('uuid4')

View File

@ -76,6 +76,11 @@ class APIModelMixin(models.Model):
self.musicbrainz_model, self.mbid)
class ArtistQuerySet(models.QuerySet):
def with_albums_count(self):
return self.annotate(_albums_count=models.Count('albums'))
class Artist(APIModelMixin):
name = models.CharField(max_length=255)
@ -89,6 +94,7 @@ class Artist(APIModelMixin):
}
}
api = musicbrainz.api.artists
objects = ArtistQuerySet.as_manager()
def __str__(self):
return self.name
@ -106,7 +112,7 @@ class Artist(APIModelMixin):
kwargs.update({'name': name})
return cls.objects.get_or_create(
name__iexact=name,
defaults=kwargs)[0]
defaults=kwargs)
def import_artist(v):
@ -129,6 +135,11 @@ def import_tracks(instance, cleaned_data, raw_data):
track = importers.load(Track, track_cleaned_data, track_data, Track.import_hooks)
class AlbumQuerySet(models.QuerySet):
def with_tracks_count(self):
return self.annotate(_tracks_count=models.Count('tracks'))
class Album(APIModelMixin):
title = models.CharField(max_length=255)
artist = models.ForeignKey(
@ -173,6 +184,7 @@ class Album(APIModelMixin):
'converter': import_artist,
}
}
objects = AlbumQuerySet.as_manager()
def get_image(self):
image_data = musicbrainz.api.images.get_front(str(self.mbid))
@ -196,7 +208,7 @@ class Album(APIModelMixin):
kwargs.update({'title': title})
return cls.objects.get_or_create(
title__iexact=title,
defaults=kwargs)[0]
defaults=kwargs)
def import_tags(instance, cleaned_data, raw_data):
@ -403,7 +415,7 @@ class Track(APIModelMixin):
kwargs.update({'title': title})
return cls.objects.get_or_create(
title__iexact=title,
defaults=kwargs)[0]
defaults=kwargs)
class TrackFile(models.Model):
@ -457,7 +469,13 @@ class TrackFile(models.Model):
def filename(self):
return '{}{}'.format(
self.track.full_name,
os.path.splitext(self.audio_file.name)[-1])
self.extension)
@property
def extension(self):
if not self.audio_file:
return
return os.path.splitext(self.audio_file.name)[-1].replace('.', '', 1)
def save(self, **kwargs):
if not self.mimetype and self.audio_file:

View File

@ -39,7 +39,7 @@ def import_track_from_remote(library_track):
except (KeyError, AssertionError):
pass
else:
return models.Track.get_or_create_from_api(mbid=track_mbid)
return models.Track.get_or_create_from_api(mbid=track_mbid)[0]
try:
album_mbid = metadata['release']['musicbrainz_id']
@ -47,9 +47,9 @@ def import_track_from_remote(library_track):
except (KeyError, AssertionError):
pass
else:
album = models.Album.get_or_create_from_api(mbid=album_mbid)
album, _ = models.Album.get_or_create_from_api(mbid=album_mbid)
return models.Track.get_or_create_from_title(
library_track.title, artist=album.artist, album=album)
library_track.title, artist=album.artist, album=album)[0]
try:
artist_mbid = metadata['artist']['musicbrainz_id']
@ -57,20 +57,20 @@ def import_track_from_remote(library_track):
except (KeyError, AssertionError):
pass
else:
artist = models.Artist.get_or_create_from_api(mbid=artist_mbid)
album = models.Album.get_or_create_from_title(
artist, _ = models.Artist.get_or_create_from_api(mbid=artist_mbid)
album, _ = models.Album.get_or_create_from_title(
library_track.album_title, artist=artist)
return models.Track.get_or_create_from_title(
library_track.title, artist=artist, album=album)
library_track.title, artist=artist, album=album)[0]
# worst case scenario, we have absolutely no way to link to a
# musicbrainz resource, we rely on the name/titles
artist = models.Artist.get_or_create_from_name(
artist, _ = models.Artist.get_or_create_from_name(
library_track.artist_name)
album = models.Album.get_or_create_from_title(
album, _ = models.Album.get_or_create_from_title(
library_track.album_title, artist=artist)
return models.Track.get_or_create_from_title(
library_track.title, artist=artist, album=album)
library_track.title, artist=artist, album=album)[0]
def _do_import(import_job, replace=False, use_acoustid=True):

View File

@ -245,6 +245,53 @@ def get_file_path(audio_file):
return path
def handle_serve(track_file):
f = track_file
# we update the accessed_date
f.accessed_date = timezone.now()
f.save(update_fields=['accessed_date'])
mt = f.mimetype
audio_file = f.audio_file
try:
library_track = f.library_track
except ObjectDoesNotExist:
library_track = None
if library_track and not audio_file:
if not library_track.audio_file:
# we need to populate from cache
with transaction.atomic():
# why the transaction/select_for_update?
# this is because browsers may send multiple requests
# in a short time range, for partial content,
# thus resulting in multiple downloads from the remote
qs = LibraryTrack.objects.select_for_update()
library_track = qs.get(pk=library_track.pk)
library_track.download_audio()
audio_file = library_track.audio_file
file_path = get_file_path(audio_file)
mt = library_track.audio_mimetype
elif audio_file:
file_path = get_file_path(audio_file)
elif f.source and f.source.startswith('file://'):
file_path = get_file_path(f.source.replace('file://', '', 1))
response = Response()
filename = f.filename
mapping = {
'nginx': 'X-Accel-Redirect',
'apache2': 'X-Sendfile',
}
file_header = mapping[settings.REVERSE_PROXY_TYPE]
response[file_header] = file_path
filename = "filename*=UTF-8''{}".format(
urllib.parse.quote(filename))
response["Content-Disposition"] = "attachment; {}".format(filename)
if mt:
response["Content-Type"] = mt
return response
class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
queryset = (models.TrackFile.objects.all().order_by('-id'))
serializer_class = serializers.TrackFileSerializer
@ -261,54 +308,10 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
'track__artist',
)
try:
f = queryset.get(pk=kwargs['pk'])
return handle_serve(queryset.get(pk=kwargs['pk']))
except models.TrackFile.DoesNotExist:
return Response(status=404)
# we update the accessed_date
f.accessed_date = timezone.now()
f.save(update_fields=['accessed_date'])
mt = f.mimetype
audio_file = f.audio_file
try:
library_track = f.library_track
except ObjectDoesNotExist:
library_track = None
if library_track and not audio_file:
if not library_track.audio_file:
# we need to populate from cache
with transaction.atomic():
# why the transaction/select_for_update?
# this is because browsers may send multiple requests
# in a short time range, for partial content,
# thus resulting in multiple downloads from the remote
qs = LibraryTrack.objects.select_for_update()
library_track = qs.get(pk=library_track.pk)
library_track.download_audio()
audio_file = library_track.audio_file
file_path = get_file_path(audio_file)
mt = library_track.audio_mimetype
elif audio_file:
file_path = get_file_path(audio_file)
elif f.source and f.source.startswith('file://'):
file_path = get_file_path(f.source.replace('file://', '', 1))
response = Response()
filename = f.filename
mapping = {
'nginx': 'X-Accel-Redirect',
'apache2': 'X-Sendfile',
}
file_header = mapping[settings.REVERSE_PROXY_TYPE]
response[file_header] = file_path
filename = "filename*=UTF-8''{}".format(
urllib.parse.quote(filename))
response["Content-Disposition"] = "attachment; {}".format(filename)
if mt:
response["Content-Type"] = mt
return response
@list_route(methods=['get'])
def viewable(self, request, *args, **kwargs):
return Response({}, status=200)

View File

@ -9,6 +9,12 @@ from funkwhale_api.common import fields
from funkwhale_api.common import preferences
class PlaylistQuerySet(models.QuerySet):
def with_tracks_count(self):
return self.annotate(
_tracks_count=models.Count('playlist_tracks'))
class Playlist(models.Model):
name = models.CharField(max_length=50)
user = models.ForeignKey(
@ -18,6 +24,8 @@ class Playlist(models.Model):
auto_now=True)
privacy_level = fields.get_privacy_field()
objects = PlaylistQuerySet.as_manager()
def __str__(self):
return self.name

View File

View File

@ -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)

View File

@ -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.'
)

View File

@ -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])

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -9,6 +9,7 @@ class UserFactory(factory.django.DjangoModelFactory):
username = factory.Sequence(lambda n: 'user-{0}'.format(n))
email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n))
password = factory.PostGenerationMethodCall('set_password', 'test')
subsonic_api_token = None
class Meta:
model = 'users.User'

View File

@ -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),
),
]

View File

@ -2,6 +2,7 @@
from __future__ import unicode_literals, absolute_import
import uuid
import secrets
from django.conf import settings
from django.contrib.auth.models import AbstractUser
@ -38,6 +39,13 @@ class User(AbstractUser):
privacy_level = fields.get_privacy_field()
# Unfortunately, Subsonic API assumes a MD5/password authentication
# scheme, which is weak in terms of security, and not achievable
# anyway since django use stronger schemes for storing passwords.
# Users that want to use the subsonic API from external client
# should set this token and use it as their password in such clients
subsonic_api_token = models.CharField(
blank=True, null=True, max_length=255)
def __str__(self):
return self.username
@ -49,9 +57,15 @@ class User(AbstractUser):
self.secret_key = uuid.uuid4()
return self.secret_key
def update_subsonic_api_token(self):
self.subsonic_api_token = secrets.token_hex(32)
return self.subsonic_api_token
def set_password(self, raw_password):
super().set_password(raw_password)
self.update_secret_key()
if self.subsonic_api_token:
self.update_subsonic_api_token()
def get_activity_url(self):
return settings.FUNKWHALE_URL + '/@{}'.format(self.username)

View File

@ -1,11 +1,13 @@
from rest_framework.response import Response
from rest_framework import mixins
from rest_framework import viewsets
from rest_framework.decorators import list_route
from rest_framework.decorators import detail_route, list_route
from rest_auth.registration.views import RegisterView as BaseRegisterView
from allauth.account.adapter import get_adapter
from funkwhale_api.common import preferences
from . import models
from . import serializers
@ -37,6 +39,28 @@ class UserViewSet(
serializer = serializers.UserReadSerializer(request.user)
return Response(serializer.data)
@detail_route(
methods=['get', 'post', 'delete'], url_path='subsonic-token')
def subsonic_token(self, request, *args, **kwargs):
if not self.request.user.username == kwargs.get('username'):
return Response(status=403)
if not preferences.get('subsonic__enabled'):
return Response(status=405)
if request.method.lower() == 'get':
return Response({
'subsonic_api_token': self.request.user.subsonic_api_token
})
if request.method.lower() == 'delete':
self.request.user.subsonic_api_token = None
self.request.user.save(update_fields=['subsonic_api_token'])
return Response(status=204)
self.request.user.update_subsonic_api_token()
self.request.user.save(update_fields=['subsonic_api_token'])
data = {
'subsonic_api_token': self.request.user.subsonic_api_token
}
return Response(data)
def update(self, request, *args, **kwargs):
if not self.request.user.username == kwargs.get('username'):
return Response(status=403)

View File

@ -130,6 +130,7 @@ def logged_in_api_client(db, factories, api_client):
"""
user = factories['users.User']()
assert api_client.login(username=user.username, password='test')
api_client.force_authenticate(user=user)
setattr(api_client, 'user', user)
yield api_client
delattr(api_client, 'user')

View File

@ -70,6 +70,32 @@ def test_wellknown_webfinger_system(
assert response.data == serializer.data
def test_wellknown_nodeinfo(db, preferences, api_client, settings):
expected = {
'links': [
{
'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0',
'href': '{}{}'.format(
settings.FUNKWHALE_URL,
reverse('api:v1:instance:nodeinfo-2.0')
)
}
]
}
url = reverse('federation:well-known-nodeinfo')
response = api_client.get(url)
assert response.status_code == 200
assert response['Content-Type'] == 'application/jrd+json'
assert response.data == expected
def test_wellknown_nodeinfo_disabled(db, preferences, api_client):
preferences['instance__nodeinfo_enabled'] = False
url = reverse('federation:well-known-nodeinfo')
response = api_client.get(url)
assert response.status_code == 404
def test_audio_file_list_requires_authenticated_actor(
db, preferences, api_client):
preferences['federation__music_needs_approval'] = True

View File

@ -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

View File

@ -3,16 +3,6 @@ from django.urls import reverse
from funkwhale_api.instance import stats
def test_can_get_stats_via_api(db, api_client, mocker):
stats = {
'foo': 'bar'
}
mocker.patch('funkwhale_api.instance.stats.get', return_value=stats)
url = reverse('api:v1:instance:stats')
response = api_client.get(url)
assert response.data == stats
def test_get_users(mocker):
mocker.patch(
'funkwhale_api.users.models.User.objects.count', return_value=42)

View File

@ -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

View File

@ -66,7 +66,7 @@ def test_import_job_from_federation_musicbrainz_recording(factories, mocker):
t = factories['music.Track']()
track_from_api = mocker.patch(
'funkwhale_api.music.models.Track.get_or_create_from_api',
return_value=t)
return_value=(t, True))
lt = factories['federation.LibraryTrack'](
metadata__recording__musicbrainz=True,
artist_name='Hello',
@ -92,7 +92,7 @@ def test_import_job_from_federation_musicbrainz_release(factories, mocker):
a = factories['music.Album']()
album_from_api = mocker.patch(
'funkwhale_api.music.models.Album.get_or_create_from_api',
return_value=a)
return_value=(a, True))
lt = factories['federation.LibraryTrack'](
metadata__release__musicbrainz=True,
artist_name='Hello',
@ -121,7 +121,7 @@ def test_import_job_from_federation_musicbrainz_artist(factories, mocker):
a = factories['music.Artist']()
artist_from_api = mocker.patch(
'funkwhale_api.music.models.Artist.get_or_create_from_api',
return_value=a)
return_value=(a, True))
lt = factories['federation.LibraryTrack'](
metadata__artist__musicbrainz=True,
album_title='World',

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -2,3 +2,17 @@
def test__str__(factories):
user = factories['users.User'](username='hello')
assert user.__str__() == 'hello'
def test_changing_password_updates_subsonic_api_token_no_token(factories):
user = factories['users.User'](subsonic_api_token=None)
user.set_password('new')
assert user.subsonic_api_token is None
def test_changing_password_updates_subsonic_api_token(factories):
user = factories['users.User'](subsonic_api_token='test')
user.set_password('new')
assert user.subsonic_api_token is not None
assert user.subsonic_api_token != 'test'

View File

@ -167,6 +167,77 @@ def test_user_can_patch_his_own_settings(logged_in_api_client):
assert user.privacy_level == 'me'
def test_user_can_request_new_subsonic_token(logged_in_api_client):
user = logged_in_api_client.user
user.subsonic_api_token = 'test'
user.save()
url = reverse(
'api:v1:users:users-subsonic-token',
kwargs={'username': user.username})
response = logged_in_api_client.post(url)
assert response.status_code == 200
user.refresh_from_db()
assert user.subsonic_api_token != 'test'
assert user.subsonic_api_token is not None
assert response.data == {
'subsonic_api_token': user.subsonic_api_token
}
def test_user_can_get_new_subsonic_token(logged_in_api_client):
user = logged_in_api_client.user
user.subsonic_api_token = 'test'
user.save()
url = reverse(
'api:v1:users:users-subsonic-token',
kwargs={'username': user.username})
response = logged_in_api_client.get(url)
assert response.status_code == 200
assert response.data == {
'subsonic_api_token': 'test'
}
def test_user_can_request_new_subsonic_token(logged_in_api_client):
user = logged_in_api_client.user
user.subsonic_api_token = 'test'
user.save()
url = reverse(
'api:v1:users:users-subsonic-token',
kwargs={'username': user.username})
response = logged_in_api_client.post(url)
assert response.status_code == 200
user.refresh_from_db()
assert user.subsonic_api_token != 'test'
assert user.subsonic_api_token is not None
assert response.data == {
'subsonic_api_token': user.subsonic_api_token
}
def test_user_can_delete_subsonic_token(logged_in_api_client):
user = logged_in_api_client.user
user.subsonic_api_token = 'test'
user.save()
url = reverse(
'api:v1:users:users-subsonic-token',
kwargs={'username': user.username})
response = logged_in_api_client.delete(url)
assert response.status_code == 204
user.refresh_from_db()
assert user.subsonic_api_token is None
@pytest.mark.parametrize('method', ['put', 'patch'])
def test_user_cannot_patch_another_user(
method, logged_in_api_client, factories):

View File

@ -5,7 +5,7 @@ demo_path="/srv/funkwhale-demo/demo"
echo 'Cleaning everything...'
cd $demo_path
docker-compose down -v || echo 'Nothing to stop'
/usr/local/bin/docker-compose down -v || echo 'Nothing to stop'
rm -rf /srv/funkwhale-demo/demo/*
mkdir -p $demo_path
echo 'Downloading demo files...'
@ -23,9 +23,10 @@ echo "DJANGO_SECRET_KEY=demo" >> .env
echo "DJANGO_ALLOWED_HOSTS=demo.funkwhale.audio" >> .env
echo "FUNKWHALE_VERSION=$version" >> .env
echo "FUNKWHALE_API_PORT=5001" >> .env
docker-compose pull
docker-compose up -d postgres redis
echo "FEDERATION_MUSIC_NEEDS_APPROVAL=False" >>.env
echo "PROTECT_AUDIO_FILES=False" >> .env
/usr/local/bin/docker-compose pull
/usr/local/bin/docker-compose up -d postgres redis
sleep 5
docker-compose run --rm api demo/load-demo-data.sh
docker-compose up -d
/usr/local/bin/docker-compose run --rm api demo/load-demo-data.sh
/usr/local/bin/docker-compose up -d

View File

@ -84,9 +84,9 @@ Define MUSIC_DIRECTORY_PATH /srv/funkwhale/data/music
ProxyPassReverse ${funkwhale-api}/federation
</Location>
<Location "/.well-known/webfinger">
ProxyPass ${funkwhale-api}/.well-known/webfinger
ProxyPassReverse ${funkwhale-api}/.well-known/webfinger
<Location "/.well-known/">
ProxyPass ${funkwhale-api}/.well-known/
ProxyPassReverse ${funkwhale-api}/.well-known/
</Location>
Alias /media /srv/funkwhale/data/media

View File

@ -48,9 +48,9 @@ FUNKWHALE_URL=https://yourdomain.funwhale
# EMAIL_CONFIG=consolemail:// # output emails to console (the default)
# EMAIL_CONFIG=dummymail:// # disable email sending completely
# On a production instance, you'll usually want to use an external SMTP server:
# EMAIL_CONFIG=smtp://user@:password@youremail.host:25'
# EMAIL_CONFIG=smtp+ssl://user@:password@youremail.host:465'
# EMAIL_CONFIG=smtp+tls://user@:password@youremail.host:587'
# EMAIL_CONFIG=smtp://user@:password@youremail.host:25
# EMAIL_CONFIG=smtp+ssl://user@:password@youremail.host:465
# EMAIL_CONFIG=smtp+tls://user@:password@youremail.host:587
# The email address to use to send systme emails. By default, we will
# DEFAULT_FROM_EMAIL=noreply@yourdomain

View File

@ -67,9 +67,9 @@ server {
proxy_pass http://funkwhale-api/federation/;
}
location /.well-known/webfinger {
location /.well-known/ {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-api/.well-known/webfinger;
proxy_pass http://funkwhale-api/.well-known/;
}
location /media/ {

View File

@ -20,7 +20,7 @@ services:
- internal
labels:
traefik.backend: "${COMPOSE_PROJECT_NAME-node1}"
traefik.frontend.rule: "Host: ${COMPOSE_PROJECT_NAME-node1}.funkwhale.test"
traefik.frontend.rule: "Host:${COMPOSE_PROJECT_NAME-node1}.funkwhale.test,${NODE_IP-127.0.0.1}"
traefik.enable: 'true'
traefik.federation.protocol: 'http'
traefik.federation.port: "${WEBPACK_DEVSERVER_PORT-8080}"

View File

@ -82,5 +82,9 @@ http {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://api:12081/;
}
location /rest/ {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://api:12081/api/subsonic/rest/;
}
}
}

View File

@ -1,5 +1,5 @@
defaultEntryPoints = ["http", "https"]
[accessLog]
################################################################
# Web configuration backend
################################################################

View File

@ -11,6 +11,7 @@ Funkwhale is a self-hosted, modern free and open-source music server, heavily in
.. toctree::
:maxdepth: 2
users/index
features
installation/index
configuration

View File

@ -77,7 +77,8 @@ Frontend setup
.. note::
You do not need to do this if you are deploying using Docker, as frontend files
are already included in the funkwhale docker image.
are already included in the docker image.
Files for the web frontend are purely static and can simply be downloaded, unzipped and served from any webserver:

View File

@ -17,29 +17,9 @@ Please take a few minutes to read the :doc:`changelog`: updates should work
similarly from version to version, but some of them may require additional steps.
Those steps would be described in the version release notes.
Upgrade the static files
------------------------
Regardless of your deployment choice (docker/non-docker) the front-end app
is updated separately from the API. This is as simple as downloading
the zip with the static files and extracting it in the correct place.
The following example assume your setup match :ref:`frontend-setup`.
.. parsed-literal::
# this assumes you want to upgrade to version "|version|"
export FUNKWHALE_VERSION="|version|"
cd /srv/funkwhale
curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front"
unzip -o front.zip
rm front.zip
Upgrading the API
-----------------
Docker setup
^^^^^^^^^^^^
------------
If you've followed the setup instructions in :doc:`Docker`, upgrade path is
easy:
@ -57,10 +37,33 @@ easy:
# Relaunch the containers
docker-compose up -d
Non-docker setup
^^^^^^^^^^^^^^^^
On non docker-setup, upgrade involves a few more commands. We assume your setup
Non-docker setup
----------------
Upgrade the static files
^^^^^^^^^^^^^^^^^^^^^^^^
On non-docker setups, the front-end app
is updated separately from the API. This is as simple as downloading
the zip with the static files and extracting it in the correct place.
The following example assume your setup match :ref:`frontend-setup`.
.. parsed-literal::
# this assumes you want to upgrade to version "|version|"
export FUNKWHALE_VERSION="|version|"
cd /srv/funkwhale
curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front"
unzip -o front.zip
rm front.zip
Upgrading the API
^^^^^^^^^^^^^^^^^
On non-docker, upgrade involves a few more commands. We assume your setup
match what is described in :doc:`debian`:
.. parsed-literal::

92
docs/users/apps.rst Normal file
View File

@ -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.

16
docs/users/index.rst Normal file
View File

@ -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

View File

@ -34,7 +34,7 @@ module.exports = {
changeOrigin: true,
ws: true,
filter: function (pathname, req) {
let proxified = ['.well-known', 'staticfiles', 'media', 'federation', 'api']
let proxified = ['rest', '.well-known', 'staticfiles', 'media', 'federation', 'api']
let matches = proxified.filter(e => {
return pathname.match(`^/${e}`)
})

View File

@ -5,7 +5,7 @@
<h1 class="ui huge header">
{{ $t('Welcome on Funkwhale') }}
</h1>
<p>{{ $t('We think listening music should be simple.') }}</p>
<p>{{ $t('We think listening to music should be simple.') }}</p>
<router-link class="ui icon button" to="/about">
<i class="info icon"></i>
{{ $t('Learn more about this instance') }}

View File

@ -2,7 +2,7 @@
<div :class="['ui', {'tiny': discrete}, 'buttons']">
<button
:title="$t('Add to current queue')"
@click="add"
@click="addNext(true)"
:class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']">
<i class="ui play icon"></i>
<template v-if="!discrete"><slot><i18next path="Play"/></slot></template>
@ -42,9 +42,7 @@ export default {
}
},
mounted () {
if (!this.discrete) {
jQuery(this.$el).find('.ui.dropdown').dropdown()
}
jQuery(this.$el).find('.ui.dropdown').dropdown()
},
computed: {
playable () {
@ -98,9 +96,11 @@ export default {
addNext (next) {
let self = this
this.triggerLoad()
let wasEmpty = this.$store.state.queue.tracks.length === 0
this.getPlayableTracks().then((tracks) => {
self.$store.dispatch('queue/appendMany', {tracks: tracks, index: self.$store.state.queue.currentIndex + 1})
if (next) {
let goNext = next && !wasEmpty
if (goNext) {
self.$store.dispatch('queue/next')
}
})

View File

@ -26,6 +26,10 @@
<div class="ui hidden divider"></div>
<div class="ui small text container">
<h2 class="ui header"><i18next path="Change my password"/></h2>
<div class="ui message">
{{ $t('Changing your password will also change your Subsonic API password if you have requested one.') }}
{{ $t('You will have to update your password on your clients that use this password.') }}
</div>
<form class="ui form" @submit.prevent="submitPassword()">
<div v-if="passwordError" class="ui negative message">
<div class="header"><i18next path="Cannot change your password"/></div>
@ -41,10 +45,25 @@
<div class="field">
<label><i18next path="New password"/></label>
<password-input required v-model="new_password" />
</div>
<button :class="['ui', {'loading': isLoading}, 'button']" type="submit"><i18next path="Change password"/></button>
<dangerous-button
color="yellow"
:class="['ui', {'loading': isLoading}, 'button']"
:action="submitPassword">
{{ $t('Change password') }}
<p slot="modal-header">{{ $t('Change your password?') }}</p>
<div slot="modal-content">
<p>{{ $t("Changing your password will have the following consequences") }}</p>
<ul>
<li>{{ $t('You will be logged out from this session and have to log out with the new one') }}</li>
<li>{{ $t('Your Subsonic password will be changed to a new, random one, logging you out from devices that used the old Subsonic password') }}</li>
</ul>
</div>
<p slot="modal-confirm">{{ $t('Disable access') }}</p>
</dangerous-button>
</form>
<div class="ui hidden divider" />
<subsonic-token-form />
</div>
</div>
</div>
@ -55,10 +74,12 @@ import $ from 'jquery'
import axios from 'axios'
import logger from '@/logging'
import PasswordInput from '@/components/forms/PasswordInput'
import SubsonicTokenForm from '@/components/auth/SubsonicTokenForm'
export default {
components: {
PasswordInput
PasswordInput,
SubsonicTokenForm
},
data () {
let d = {

View File

@ -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>

View File

@ -3,7 +3,7 @@
<div v-if="stats" class="ui stackable two column grid">
<div class="column">
<h3 class="ui left aligned header"><i18next path="User activity"/></h3>
<div class="ui mini horizontal statistics">
<div v-if="stats" class="ui mini horizontal statistics">
<div class="statistic">
<div class="value">
<i class="green user icon"></i>
@ -19,7 +19,7 @@
</div>
<div class="statistic">
<div class="value">
<i class="pink heart icon"></i> {{ stats.track_favorites }}
<i class="pink heart icon"></i> {{ stats.trackFavorites }}
</div>
<i18next tag="div" class="label" path="Tracks favorited"/>
</div>
@ -30,7 +30,7 @@
<div class="ui mini horizontal statistics">
<div class="statistic">
<div class="value">
{{ parseInt(stats.music_duration) }}
{{ parseInt(stats.musicDuration) }}
</div>
<i18next tag="div" class="label" path="hours of music"/>
</div>
@ -59,6 +59,7 @@
</template>
<script>
import _ from 'lodash'
import axios from 'axios'
import logger from '@/logging'
@ -76,8 +77,16 @@ export default {
var self = this
this.isLoading = true
logger.default.debug('Fetching instance stats...')
axios.get('instance/stats/').then((response) => {
self.stats = response.data
axios.get('instance/nodeinfo/2.0/').then((response) => {
let d = response.data
self.stats = {}
self.stats.users = _.get(d, 'usage.users.total')
self.stats.listenings = _.get(d, 'metadata.usage.listenings.total')
self.stats.trackFavorites = _.get(d, 'metadata.usage.favorites.tracks.total')
self.stats.musicDuration = _.get(d, 'metadata.library.music.hours')
self.stats.artists = _.get(d, 'metadata.library.artists.total')
self.stats.albums = _.get(d, 'metadata.library.albums.total')
self.stats.tracks = _.get(d, 'metadata.library.tracks.total')
self.isLoading = false
})
}

View File

@ -24,6 +24,11 @@ export default {
value: true
}
},
subsonic: {
enabled: {
value: true
}
},
raven: {
front_enabled: {
value: false