Verified Commit bbd27340 authored by Agate's avatar Agate 💬

See #75: initial subsonic implementation that works with http://p.subfireplayer.net

parent 96822994
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'])
......@@ -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')
......
......@@ -457,7 +457,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:
......
......@@ -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)
......
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)
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)
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
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
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']()
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')
......
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
import json
import xml.etree.ElementTree as ET