See #75: initial subsonic implementation that works with http://p.subfireplayer.net
This commit is contained in:
parent
9682299480
commit
bbd273404a
|
@ -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'])
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -457,7 +457,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:
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,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,100 @@
|
||||||
|
import collections
|
||||||
|
|
||||||
|
from django.db.models import functions, Count
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class GetArtistsSerializer(serializers.Serializer):
|
||||||
|
def to_representation(self, queryset):
|
||||||
|
payload = {
|
||||||
|
'ignoredArticles': '',
|
||||||
|
'index': []
|
||||||
|
}
|
||||||
|
queryset = queryset.annotate(_albums_count=Count('albums'))
|
||||||
|
queryset = queryset.order_by(functions.Lower('name'))
|
||||||
|
values = queryset.values('id', '_albums_count', 'name')
|
||||||
|
|
||||||
|
first_letter_mapping = collections.defaultdict(list)
|
||||||
|
for artist in values:
|
||||||
|
first_letter_mapping[artist['name'][0].upper()].append(artist)
|
||||||
|
|
||||||
|
for letter, artists in sorted(first_letter_mapping.items()):
|
||||||
|
letter_data = {
|
||||||
|
'name': letter,
|
||||||
|
'artist': [
|
||||||
|
{
|
||||||
|
'id': v['id'],
|
||||||
|
'name': v['name'],
|
||||||
|
'albumCount': v['_albums_count']
|
||||||
|
}
|
||||||
|
for v in artists
|
||||||
|
]
|
||||||
|
}
|
||||||
|
payload['index'].append(letter_data)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class GetArtistSerializer(serializers.Serializer):
|
||||||
|
def to_representation(self, artist):
|
||||||
|
albums = artist.albums.prefetch_related('tracks__files')
|
||||||
|
payload = {
|
||||||
|
'id': artist.pk,
|
||||||
|
'name': artist.name,
|
||||||
|
'albumCount': len(albums),
|
||||||
|
'album': [],
|
||||||
|
}
|
||||||
|
for album in albums:
|
||||||
|
album_data = {
|
||||||
|
'id': album.id,
|
||||||
|
'artistId': artist.id,
|
||||||
|
'name': album.title,
|
||||||
|
'artist': artist.name,
|
||||||
|
'created': album.creation_date,
|
||||||
|
'songCount': len(album.tracks.all())
|
||||||
|
}
|
||||||
|
if album.release_date:
|
||||||
|
album_data['year'] = album.release_date.year
|
||||||
|
payload['album'].append(album_data)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class GetAlbumSerializer(serializers.Serializer):
|
||||||
|
def to_representation(self, album):
|
||||||
|
tracks = album.tracks.prefetch_related('files')
|
||||||
|
payload = {
|
||||||
|
'id': album.id,
|
||||||
|
'artistId': album.artist.id,
|
||||||
|
'name': album.title,
|
||||||
|
'artist': album.artist.name,
|
||||||
|
'created': album.creation_date,
|
||||||
|
'songCount': len(tracks),
|
||||||
|
'song': [],
|
||||||
|
}
|
||||||
|
if album.release_date:
|
||||||
|
payload['year'] = album.release_date.year
|
||||||
|
|
||||||
|
for track in tracks:
|
||||||
|
try:
|
||||||
|
tf = [tf for tf in track.files.all()][0]
|
||||||
|
except IndexError:
|
||||||
|
continue
|
||||||
|
track_data = {
|
||||||
|
'id': track.pk,
|
||||||
|
'isDir': False,
|
||||||
|
'title': track.title,
|
||||||
|
'album': album.title,
|
||||||
|
'artist': album.artist.name,
|
||||||
|
'track': track.position,
|
||||||
|
'contentType': tf.mimetype,
|
||||||
|
'suffix': tf.extension,
|
||||||
|
'duration': tf.duration,
|
||||||
|
'created': track.creation_date,
|
||||||
|
'albumId': album.pk,
|
||||||
|
'artistId': album.artist.pk,
|
||||||
|
'type': 'music',
|
||||||
|
}
|
||||||
|
if album.release_date:
|
||||||
|
track_data['year'] = album.release_date.year
|
||||||
|
payload['song'].append(track_data)
|
||||||
|
return payload
|
|
@ -0,0 +1,143 @@
|
||||||
|
from rest_framework import exceptions
|
||||||
|
from rest_framework import permissions as rest_permissions
|
||||||
|
from rest_framework import response
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.decorators import list_route
|
||||||
|
from rest_framework.serializers import ValidationError
|
||||||
|
|
||||||
|
from funkwhale_api.music import models as music_models
|
||||||
|
from funkwhale_api.music import views as music_views
|
||||||
|
|
||||||
|
from . import authentication
|
||||||
|
from . import negotiation
|
||||||
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
|
def find_object(queryset, model_field='pk', field='id', cast=int):
|
||||||
|
def decorator(func):
|
||||||
|
def inner(self, request, *args, **kwargs):
|
||||||
|
data = request.GET or request.POST
|
||||||
|
try:
|
||||||
|
raw_value = data[field]
|
||||||
|
except KeyError:
|
||||||
|
return response.Response({
|
||||||
|
'code': 10,
|
||||||
|
'message': "required parameter '{}' not present".format(field)
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
value = cast(raw_value)
|
||||||
|
except (TypeError, ValidationError):
|
||||||
|
return response.Response({
|
||||||
|
'code': 0,
|
||||||
|
'message': 'For input string "{}"'.format(raw_value)
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
obj = queryset.get(**{model_field: value})
|
||||||
|
except queryset.model.DoesNotExist:
|
||||||
|
return response.Response({
|
||||||
|
'code': 70,
|
||||||
|
'message': '{} not found'.format(
|
||||||
|
queryset.model.__class__.__name__)
|
||||||
|
})
|
||||||
|
kwargs['obj'] = obj
|
||||||
|
return func(self, request, *args, **kwargs)
|
||||||
|
return inner
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
class SubsonicViewSet(viewsets.GenericViewSet):
|
||||||
|
content_negotiation_class = negotiation.SubsonicContentNegociation
|
||||||
|
authentication_classes = [authentication.SubsonicAuthentication]
|
||||||
|
permissions_classes = [rest_permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def handle_exception(self, exc):
|
||||||
|
# subsonic API sends 200 status code with custom error
|
||||||
|
# codes in the payload
|
||||||
|
mapping = {
|
||||||
|
exceptions.AuthenticationFailed: (
|
||||||
|
40, 'Wrong username or password.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
'status': 'failed'
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
code, message = mapping[exc.__class__]
|
||||||
|
except KeyError:
|
||||||
|
return super().handle_exception(exc)
|
||||||
|
else:
|
||||||
|
payload['error'] = {
|
||||||
|
'code': code,
|
||||||
|
'message': message
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Response(payload, status=200)
|
||||||
|
|
||||||
|
@list_route(
|
||||||
|
methods=['get', 'post'],
|
||||||
|
permission_classes=[])
|
||||||
|
def ping(self, request, *args, **kwargs):
|
||||||
|
data = {
|
||||||
|
'status': 'ok',
|
||||||
|
'version': '1.16.0'
|
||||||
|
}
|
||||||
|
return response.Response(data, status=200)
|
||||||
|
|
||||||
|
@list_route(
|
||||||
|
methods=['get', 'post'],
|
||||||
|
url_name='get_artists',
|
||||||
|
url_path='getArtists')
|
||||||
|
def get_artists(self, request, *args, **kwargs):
|
||||||
|
artists = music_models.Artist.objects.all()
|
||||||
|
data = serializers.GetArtistsSerializer(artists).data
|
||||||
|
payload = {
|
||||||
|
'artists': data
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Response(payload, status=200)
|
||||||
|
|
||||||
|
@list_route(
|
||||||
|
methods=['get', 'post'],
|
||||||
|
url_name='get_artist',
|
||||||
|
url_path='getArtist')
|
||||||
|
@find_object(music_models.Artist.objects.all())
|
||||||
|
def get_artist(self, request, *args, **kwargs):
|
||||||
|
artist = kwargs.pop('obj')
|
||||||
|
data = serializers.GetArtistSerializer(artist).data
|
||||||
|
payload = {
|
||||||
|
'artist': data
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Response(payload, status=200)
|
||||||
|
|
||||||
|
@list_route(
|
||||||
|
methods=['get', 'post'],
|
||||||
|
url_name='get_album',
|
||||||
|
url_path='getAlbum')
|
||||||
|
@find_object(
|
||||||
|
music_models.Album.objects.select_related('artist'))
|
||||||
|
def get_album(self, request, *args, **kwargs):
|
||||||
|
album = kwargs.pop('obj')
|
||||||
|
data = serializers.GetAlbumSerializer(album).data
|
||||||
|
payload = {
|
||||||
|
'album': data
|
||||||
|
}
|
||||||
|
return response.Response(payload, status=200)
|
||||||
|
|
||||||
|
@list_route(
|
||||||
|
methods=['get', 'post'],
|
||||||
|
url_name='stream',
|
||||||
|
url_path='stream')
|
||||||
|
@find_object(
|
||||||
|
music_models.Track.objects.all())
|
||||||
|
def stream(self, request, *args, **kwargs):
|
||||||
|
track = kwargs.pop('obj')
|
||||||
|
queryset = track.files.select_related(
|
||||||
|
'library_track',
|
||||||
|
'track__album__artist',
|
||||||
|
'track__artist',
|
||||||
|
)
|
||||||
|
track_file = queryset.first()
|
||||||
|
if not track_file:
|
||||||
|
return Response(status=404)
|
||||||
|
return music_views.handle_serve(track_file)
|
|
@ -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')
|
||||||
|
|
|
@ -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,109 @@
|
||||||
|
from funkwhale_api.subsonic import serializers
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_artists_serializer(factories):
|
||||||
|
artist1 = factories['music.Artist'](name='eliot')
|
||||||
|
artist2 = factories['music.Artist'](name='Ellena')
|
||||||
|
artist3 = factories['music.Artist'](name='Rilay')
|
||||||
|
|
||||||
|
factories['music.Album'].create_batch(size=3, artist=artist1)
|
||||||
|
factories['music.Album'].create_batch(size=2, artist=artist2)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
'ignoredArticles': '',
|
||||||
|
'index': [
|
||||||
|
{
|
||||||
|
'name': 'E',
|
||||||
|
'artist': [
|
||||||
|
{
|
||||||
|
'id': artist1.pk,
|
||||||
|
'name': artist1.name,
|
||||||
|
'albumCount': 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': artist2.pk,
|
||||||
|
'name': artist2.name,
|
||||||
|
'albumCount': 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'R',
|
||||||
|
'artist': [
|
||||||
|
{
|
||||||
|
'id': artist3.pk,
|
||||||
|
'name': artist3.name,
|
||||||
|
'albumCount': 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
queryset = artist1.__class__.objects.filter(pk__in=[
|
||||||
|
artist1.pk, artist2.pk, artist3.pk
|
||||||
|
])
|
||||||
|
|
||||||
|
assert serializers.GetArtistsSerializer(queryset).data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_artist_serializer(factories):
|
||||||
|
artist = factories['music.Artist']()
|
||||||
|
album = factories['music.Album'](artist=artist)
|
||||||
|
tracks = factories['music.Track'].create_batch(size=3, album=album)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
'id': artist.pk,
|
||||||
|
'name': artist.name,
|
||||||
|
'albumCount': 1,
|
||||||
|
'album': [
|
||||||
|
{
|
||||||
|
'id': album.pk,
|
||||||
|
'artistId': artist.pk,
|
||||||
|
'name': album.title,
|
||||||
|
'artist': artist.name,
|
||||||
|
'songCount': len(tracks),
|
||||||
|
'created': album.creation_date,
|
||||||
|
'year': album.release_date.year,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert serializers.GetArtistSerializer(artist).data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_album_serializer(factories):
|
||||||
|
artist = factories['music.Artist']()
|
||||||
|
album = factories['music.Album'](artist=artist)
|
||||||
|
track = factories['music.Track'](album=album)
|
||||||
|
tf = factories['music.TrackFile'](track=track)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
'id': album.pk,
|
||||||
|
'artistId': artist.pk,
|
||||||
|
'name': album.title,
|
||||||
|
'artist': artist.name,
|
||||||
|
'songCount': 1,
|
||||||
|
'created': album.creation_date,
|
||||||
|
'year': album.release_date.year,
|
||||||
|
'song': [
|
||||||
|
{
|
||||||
|
'id': track.pk,
|
||||||
|
'isDir': False,
|
||||||
|
'title': track.title,
|
||||||
|
'album': album.title,
|
||||||
|
'artist': artist.name,
|
||||||
|
'track': track.position,
|
||||||
|
'year': track.album.release_date.year,
|
||||||
|
'contentType': tf.mimetype,
|
||||||
|
'suffix': tf.extension,
|
||||||
|
'duration': tf.duration,
|
||||||
|
'created': track.creation_date,
|
||||||
|
'albumId': album.pk,
|
||||||
|
'artistId': artist.pk,
|
||||||
|
'type': 'music',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert serializers.GetAlbumSerializer(album).data == expected
|
|
@ -0,0 +1,120 @@
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from funkwhale_api.music import models as music_models
|
||||||
|
from funkwhale_api.music import views as music_views
|
||||||
|
from funkwhale_api.subsonic import renderers
|
||||||
|
from funkwhale_api.subsonic import serializers
|
||||||
|
|
||||||
|
|
||||||
|
def render_json(data):
|
||||||
|
return json.loads(renderers.SubsonicJSONRenderer().render(data))
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_content_json(db, api_client):
|
||||||
|
url = reverse('api:subsonic-ping')
|
||||||
|
response = api_client.get(url, {'f': 'json'})
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
'status': 'ok',
|
||||||
|
'version': '1.16.0'
|
||||||
|
}
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert json.loads(response.content) == render_json(expected)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('f', ['xml', 'json'])
|
||||||
|
def test_exception_wrong_credentials(f, db, api_client):
|
||||||
|
url = reverse('api:subsonic-ping')
|
||||||
|
response = api_client.get(url, {'f': f, 'u': 'yolo'})
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
'status': 'failed',
|
||||||
|
'error': {
|
||||||
|
'code': 40,
|
||||||
|
'message': 'Wrong username or password.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('f', ['xml', 'json'])
|
||||||
|
def test_ping(f, db, api_client):
|
||||||
|
url = reverse('api:subsonic-ping')
|
||||||
|
response = api_client.get(url, {'f': f})
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
'status': 'ok',
|
||||||
|
'version': '1.16.0',
|
||||||
|
}
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('f', ['xml', 'json'])
|
||||||
|
def test_get_artists(f, db, logged_in_api_client, factories):
|
||||||
|
url = reverse('api:subsonic-get-artists')
|
||||||
|
assert url.endswith('getArtists') is True
|
||||||
|
artists = factories['music.Artist'].create_batch(size=10)
|
||||||
|
expected = {
|
||||||
|
'artists': serializers.GetArtistsSerializer(
|
||||||
|
music_models.Artist.objects.all()
|
||||||
|
).data
|
||||||
|
}
|
||||||
|
response = logged_in_api_client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('f', ['xml', 'json'])
|
||||||
|
def test_get_artist(f, db, logged_in_api_client, factories):
|
||||||
|
url = reverse('api:subsonic-get-artist')
|
||||||
|
assert url.endswith('getArtist') is True
|
||||||
|
artist = factories['music.Artist']()
|
||||||
|
albums = factories['music.Album'].create_batch(size=3, artist=artist)
|
||||||
|
expected = {
|
||||||
|
'artist': serializers.GetArtistSerializer(artist).data
|
||||||
|
}
|
||||||
|
response = logged_in_api_client.get(url, {'id': artist.pk})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('f', ['xml', 'json'])
|
||||||
|
def test_get_album(f, db, logged_in_api_client, factories):
|
||||||
|
url = reverse('api:subsonic-get-album')
|
||||||
|
assert url.endswith('getAlbum') is True
|
||||||
|
artist = factories['music.Artist']()
|
||||||
|
album = factories['music.Album'](artist=artist)
|
||||||
|
tracks = factories['music.Track'].create_batch(size=3, album=album)
|
||||||
|
expected = {
|
||||||
|
'album': serializers.GetAlbumSerializer(album).data
|
||||||
|
}
|
||||||
|
response = logged_in_api_client.get(url, {'f': f, 'id': album.pk})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('f', ['xml', 'json'])
|
||||||
|
def test_stream(f, db, logged_in_api_client, factories, mocker):
|
||||||
|
url = reverse('api:subsonic-stream')
|
||||||
|
mocked_serve = mocker.spy(
|
||||||
|
music_views, 'handle_serve')
|
||||||
|
assert url.endswith('stream') is True
|
||||||
|
artist = factories['music.Artist']()
|
||||||
|
album = factories['music.Album'](artist=artist)
|
||||||
|
track = factories['music.Track'](album=album)
|
||||||
|
tf = factories['music.TrackFile'](track=track)
|
||||||
|
response = logged_in_api_client.get(url, {'f': f, 'id': track.pk})
|
||||||
|
|
||||||
|
mocked_serve.assert_called_once_with(
|
||||||
|
track_file=tf
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
Loading…
Reference in New Issue