Merge branch '75-subsonic-api' into 'develop'

Resolve "Implement the subsonic API"

Closes #75

See merge request funkwhale/funkwhale!188
This commit is contained in:
Eliot Berriot 2018-05-09 21:27:57 +00:00
commit 805f9c6bbc
38 changed files with 2157 additions and 61 deletions

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

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

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

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

@ -0,0 +1,43 @@
Subsonic API implementation to offer compatibility with existing clients such as DSub (#75)
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.

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

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

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

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

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