views.py 17.1 KB
Newer Older
1 2
import datetime

3
from django.conf import settings
4 5
from django.utils import timezone

6 7
from rest_framework import exceptions
from rest_framework import permissions as rest_permissions
8
from rest_framework import renderers
9 10 11 12 13
from rest_framework import response
from rest_framework import viewsets
from rest_framework.decorators import list_route
from rest_framework.serializers import ValidationError

14
from funkwhale_api.activity import record
15
from funkwhale_api.common import preferences
16
from funkwhale_api.favorites.models import TrackFavorite
17
from funkwhale_api.music import models as music_models
18
from funkwhale_api.music import utils
19
from funkwhale_api.music import views as music_views
20
from funkwhale_api.playlists import models as playlists_models
21 22

from . import authentication
23
from . import filters
24 25 26 27
from . import negotiation
from . import serializers


Agate's avatar
Agate committed
28
def find_object(queryset, model_field="pk", field="id", cast=int):
29 30 31 32 33 34
    def decorator(func):
        def inner(self, request, *args, **kwargs):
            data = request.GET or request.POST
            try:
                raw_value = data[field]
            except KeyError:
Agate's avatar
Agate committed
35 36 37 38 39 40 41 42
                return response.Response(
                    {
                        "error": {
                            "code": 10,
                            "message": "required parameter '{}' not present".format(
                                field
                            ),
                        }
43
                    }
Agate's avatar
Agate committed
44
                )
45 46 47
            try:
                value = cast(raw_value)
            except (TypeError, ValidationError):
Agate's avatar
Agate committed
48 49 50 51 52 53
                return response.Response(
                    {
                        "error": {
                            "code": 0,
                            "message": 'For input string "{}"'.format(raw_value),
                        }
54
                    }
Agate's avatar
Agate committed
55
                )
56
            qs = queryset
Agate's avatar
Agate committed
57
            if hasattr(qs, "__call__"):
58
                qs = qs(request)
59
            try:
60 61
                obj = qs.get(**{model_field: value})
            except qs.model.DoesNotExist:
Agate's avatar
Agate committed
62 63 64 65 66 67 68 69
                return response.Response(
                    {
                        "error": {
                            "code": 70,
                            "message": "{} not found".format(
                                qs.model.__class__.__name__
                            ),
                        }
70
                    }
Agate's avatar
Agate committed
71 72
                )
            kwargs["obj"] = obj
73
            return func(self, request, *args, **kwargs)
Agate's avatar
Agate committed
74

75
        return inner
Agate's avatar
Agate committed
76

77 78 79 80 81 82 83 84
    return decorator


class SubsonicViewSet(viewsets.GenericViewSet):
    content_negotiation_class = negotiation.SubsonicContentNegociation
    authentication_classes = [authentication.SubsonicAuthentication]
    permissions_classes = [rest_permissions.IsAuthenticated]

85
    def dispatch(self, request, *args, **kwargs):
Agate's avatar
Agate committed
86
        if not preferences.get("subsonic__enabled"):
87 88
            r = response.Response({}, status=405)
            r.accepted_renderer = renderers.JSONRenderer()
Agate's avatar
Agate committed
89
            r.accepted_media_type = "application/json"
90 91 92 93
            r.renderer_context = {}
            return r
        return super().dispatch(request, *args, **kwargs)

94 95 96
    def handle_exception(self, exc):
        # subsonic API sends 200 status code with custom error
        # codes in the payload
Agate's avatar
Agate committed
97 98
        mapping = {exceptions.AuthenticationFailed: (40, "Wrong username or password.")}
        payload = {"status": "failed"}
99
        if exc.__class__ in mapping:
100 101
            code, message = mapping[exc.__class__]
        else:
102
            return super().handle_exception(exc)
Agate's avatar
Agate committed
103
        payload["error"] = {"code": code, "message": message}
104 105 106

        return response.Response(payload, status=200)

Agate's avatar
Agate committed
107
    @list_route(methods=["get", "post"], permission_classes=[])
108
    def ping(self, request, *args, **kwargs):
Agate's avatar
Agate committed
109
        data = {"status": "ok", "version": "1.16.0"}
110 111
        return response.Response(data, status=200)

112
    @list_route(
Agate's avatar
Agate committed
113 114
        methods=["get", "post"],
        url_name="get_license",
115
        permissions_classes=[],
Agate's avatar
Agate committed
116 117
        url_path="getLicense",
    )
118 119 120
    def get_license(self, request, *args, **kwargs):
        now = timezone.now()
        data = {
Agate's avatar
Agate committed
121 122 123 124 125 126 127
            "status": "ok",
            "version": "1.16.0",
            "license": {
                "valid": "true",
                "email": "valid@valid.license",
                "licenseExpires": now + datetime.timedelta(days=365),
            },
128 129 130
        }
        return response.Response(data, status=200)

Agate's avatar
Agate committed
131
    @list_route(methods=["get", "post"], url_name="get_artists", url_path="getArtists")
132 133 134
    def get_artists(self, request, *args, **kwargs):
        artists = music_models.Artist.objects.all()
        data = serializers.GetArtistsSerializer(artists).data
Agate's avatar
Agate committed
135
        payload = {"artists": data}
136 137 138

        return response.Response(payload, status=200)

Agate's avatar
Agate committed
139
    @list_route(methods=["get", "post"], url_name="get_indexes", url_path="getIndexes")
140 141 142
    def get_indexes(self, request, *args, **kwargs):
        artists = music_models.Artist.objects.all()
        data = serializers.GetArtistsSerializer(artists).data
Agate's avatar
Agate committed
143
        payload = {"indexes": data}
144 145 146

        return response.Response(payload, status=200)

Agate's avatar
Agate committed
147
    @list_route(methods=["get", "post"], url_name="get_artist", url_path="getArtist")
148 149
    @find_object(music_models.Artist.objects.all())
    def get_artist(self, request, *args, **kwargs):
Agate's avatar
Agate committed
150
        artist = kwargs.pop("obj")
151
        data = serializers.GetArtistSerializer(artist).data
Agate's avatar
Agate committed
152
        payload = {"artist": data}
153 154 155

        return response.Response(payload, status=200)

156
    @list_route(
Agate's avatar
Agate committed
157 158
        methods=["get", "post"], url_name="get_artist_info2", url_path="getArtistInfo2"
    )
159 160
    @find_object(music_models.Artist.objects.all())
    def get_artist_info2(self, request, *args, **kwargs):
Agate's avatar
Agate committed
161
        payload = {"artist-info2": {}}
162 163 164

        return response.Response(payload, status=200)

Agate's avatar
Agate committed
165 166
    @list_route(methods=["get", "post"], url_name="get_album", url_path="getAlbum")
    @find_object(music_models.Album.objects.select_related("artist"))
167
    def get_album(self, request, *args, **kwargs):
Agate's avatar
Agate committed
168
        album = kwargs.pop("obj")
169
        data = serializers.GetAlbumSerializer(album).data
Agate's avatar
Agate committed
170
        payload = {"album": data}
171 172
        return response.Response(payload, status=200)

Agate's avatar
Agate committed
173 174
    @list_route(methods=["get", "post"], url_name="stream", url_path="stream")
    @find_object(music_models.Track.objects.all())
175
    def stream(self, request, *args, **kwargs):
Agate's avatar
Agate committed
176
        track = kwargs.pop("obj")
177
        queryset = track.files.select_related(
Agate's avatar
Agate committed
178
            "library_track", "track__album__artist", "track__artist"
179 180 181
        )
        track_file = queryset.first()
        if not track_file:
182
            return response.Response(status=404)
183
        return music_views.handle_serve(track_file)
184

Agate's avatar
Agate committed
185 186
    @list_route(methods=["get", "post"], url_name="star", url_path="star")
    @find_object(music_models.Track.objects.all())
187
    def star(self, request, *args, **kwargs):
Agate's avatar
Agate committed
188
        track = kwargs.pop("obj")
189
        TrackFavorite.add(user=request.user, track=track)
Agate's avatar
Agate committed
190
        return response.Response({"status": "ok"})
191

Agate's avatar
Agate committed
192 193
    @list_route(methods=["get", "post"], url_name="unstar", url_path="unstar")
    @find_object(music_models.Track.objects.all())
194
    def unstar(self, request, *args, **kwargs):
Agate's avatar
Agate committed
195
        track = kwargs.pop("obj")
196
        request.user.track_favorites.filter(track=track).delete()
Agate's avatar
Agate committed
197
        return response.Response({"status": "ok"})
198 199

    @list_route(
Agate's avatar
Agate committed
200 201
        methods=["get", "post"], url_name="get_starred2", url_path="getStarred2"
    )
202 203
    def get_starred2(self, request, *args, **kwargs):
        favorites = request.user.track_favorites.all()
Agate's avatar
Agate committed
204
        data = {"starred2": {"song": serializers.get_starred_tracks_data(favorites)}}
205 206
        return response.Response(data)

Agate's avatar
Agate committed
207
    @list_route(methods=["get", "post"], url_name="get_starred", url_path="getStarred")
208 209
    def get_starred(self, request, *args, **kwargs):
        favorites = request.user.track_favorites.all()
Agate's avatar
Agate committed
210
        data = {"starred": {"song": serializers.get_starred_tracks_data(favorites)}}
211 212 213
        return response.Response(data)

    @list_route(
Agate's avatar
Agate committed
214 215
        methods=["get", "post"], url_name="get_album_list2", url_path="getAlbumList2"
    )
216 217 218 219 220 221
    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:
Agate's avatar
Agate committed
222
            offset = int(data["offset"])
223 224 225 226
        except (TypeError, KeyError, ValueError):
            offset = 0

        try:
Agate's avatar
Agate committed
227
            size = int(data["size"])
228 229 230 231 232
        except (TypeError, KeyError, ValueError):
            size = 50

        size = min(size, 500)
        queryset = queryset[offset:size]
Agate's avatar
Agate committed
233
        data = {"albumList2": {"album": serializers.get_album_list2_data(queryset)}}
234 235
        return response.Response(data)

Agate's avatar
Agate committed
236
    @list_route(methods=["get", "post"], url_name="search3", url_path="search3")
237 238
    def search3(self, request, *args, **kwargs):
        data = request.GET or request.POST
Agate's avatar
Agate committed
239
        query = str(data.get("query", "")).replace("*", "")
240 241
        conf = [
            {
Agate's avatar
Agate committed
242 243 244 245 246 247
                "subsonic": "artist",
                "search_fields": ["name"],
                "queryset": (
                    music_models.Artist.objects.with_albums_count().values(
                        "id", "_albums_count", "name"
                    )
248
                ),
Agate's avatar
Agate committed
249
                "serializer": lambda qs: [serializers.get_artist_data(a) for a in qs],
250 251
            },
            {
Agate's avatar
Agate committed
252 253 254 255 256 257
                "subsonic": "album",
                "search_fields": ["title"],
                "queryset": (
                    music_models.Album.objects.with_tracks_count().select_related(
                        "artist"
                    )
258
                ),
Agate's avatar
Agate committed
259
                "serializer": serializers.get_album_list2_data,
260 261
            },
            {
Agate's avatar
Agate committed
262 263 264 265 266 267
                "subsonic": "song",
                "search_fields": ["title"],
                "queryset": (
                    music_models.Track.objects.prefetch_related("files").select_related(
                        "album__artist"
                    )
268
                ),
Agate's avatar
Agate committed
269
                "serializer": serializers.get_song_list_data,
270 271
            },
        ]
Agate's avatar
Agate committed
272
        payload = {"searchResult3": {}}
273
        for c in conf:
Agate's avatar
Agate committed
274 275
            offsetKey = "{}Offset".format(c["subsonic"])
            countKey = "{}Count".format(c["subsonic"])
276 277 278 279 280 281 282 283 284 285 286
            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)
Agate's avatar
Agate committed
287
            queryset = c["queryset"]
288
            if query:
Agate's avatar
Agate committed
289 290
                queryset = c["queryset"].filter(
                    utils.get_query(query, c["search_fields"])
291 292
                )
            queryset = queryset[offset:size]
Agate's avatar
Agate committed
293
            payload["searchResult3"][c["subsonic"]] = c["serializer"](queryset)
294
        return response.Response(payload)
295 296

    @list_route(
Agate's avatar
Agate committed
297 298
        methods=["get", "post"], url_name="get_playlists", url_path="getPlaylists"
    )
299
    def get_playlists(self, request, *args, **kwargs):
Agate's avatar
Agate committed
300
        playlists = request.user.playlists.with_tracks_count().select_related("user")
301
        data = {
Agate's avatar
Agate committed
302 303
            "playlists": {
                "playlist": [serializers.get_playlist_data(p) for p in playlists]
304 305 306 307 308
            }
        }
        return response.Response(data)

    @list_route(
Agate's avatar
Agate committed
309 310 311
        methods=["get", "post"], url_name="get_playlist", url_path="getPlaylist"
    )
    @find_object(playlists_models.Playlist.objects.with_tracks_count())
312
    def get_playlist(self, request, *args, **kwargs):
Agate's avatar
Agate committed
313 314
        playlist = kwargs.pop("obj")
        data = {"playlist": serializers.get_playlist_detail_data(playlist)}
315 316 317
        return response.Response(data)

    @list_route(
Agate's avatar
Agate committed
318 319 320
        methods=["get", "post"], url_name="update_playlist", url_path="updatePlaylist"
    )
    @find_object(lambda request: request.user.playlists.all(), field="playlistId")
321
    def update_playlist(self, request, *args, **kwargs):
Agate's avatar
Agate committed
322
        playlist = kwargs.pop("obj")
323
        data = request.GET or request.POST
Agate's avatar
Agate committed
324
        new_name = data.get("name", "")
325 326
        if new_name:
            playlist.name = new_name
Agate's avatar
Agate committed
327
            playlist.save(update_fields=["name", "modification_date"])
328
        try:
Agate's avatar
Agate committed
329
            to_remove = int(data["songIndexToRemove"])
330 331 332 333 334 335 336 337
            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)

338
        ids = []
Agate's avatar
Agate committed
339
        for i in data.getlist("songIdToAdd"):
340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355
            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)

Agate's avatar
Agate committed
356
        data = {"status": "ok"}
357 358 359
        return response.Response(data)

    @list_route(
Agate's avatar
Agate committed
360 361 362
        methods=["get", "post"], url_name="delete_playlist", url_path="deletePlaylist"
    )
    @find_object(lambda request: request.user.playlists.all())
363
    def delete_playlist(self, request, *args, **kwargs):
Agate's avatar
Agate committed
364
        playlist = kwargs.pop("obj")
365
        playlist.delete()
Agate's avatar
Agate committed
366
        data = {"status": "ok"}
367 368 369
        return response.Response(data)

    @list_route(
Agate's avatar
Agate committed
370 371
        methods=["get", "post"], url_name="create_playlist", url_path="createPlaylist"
    )
372 373
    def create_playlist(self, request, *args, **kwargs):
        data = request.GET or request.POST
Agate's avatar
Agate committed
374
        name = data.get("name", "")
375
        if not name:
Agate's avatar
Agate committed
376 377 378 379 380 381
            return response.Response(
                {
                    "error": {
                        "code": 10,
                        "message": "Playlist ID or name must be specified.",
                    }
382
                }
Agate's avatar
Agate committed
383
            )
384

Agate's avatar
Agate committed
385
        playlist = request.user.playlists.create(name=name)
386
        ids = []
Agate's avatar
Agate committed
387
        for i in data.getlist("songId"):
388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403
            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)
Agate's avatar
Agate committed
404 405
        playlist = request.user.playlists.with_tracks_count().get(pk=playlist.pk)
        data = {"playlist": serializers.get_playlist_detail_data(playlist)}
406
        return response.Response(data)
407 408

    @list_route(
Agate's avatar
Agate committed
409 410 411 412
        methods=["get", "post"],
        url_name="get_music_folders",
        url_path="getMusicFolders",
    )
413
    def get_music_folders(self, request, *args, **kwargs):
Agate's avatar
Agate committed
414
        data = {"musicFolders": {"musicFolder": [{"id": 1, "name": "Music"}]}}
415
        return response.Response(data)
416 417

    @list_route(
Agate's avatar
Agate committed
418 419
        methods=["get", "post"], url_name="get_cover_art", url_path="getCoverArt"
    )
420 421
    def get_cover_art(self, request, *args, **kwargs):
        data = request.GET or request.POST
Agate's avatar
Agate committed
422
        id = data.get("id", "")
423
        if not id:
Agate's avatar
Agate committed
424 425 426
            return response.Response(
                {"error": {"code": 10, "message": "cover art ID must be specified."}}
            )
427

Agate's avatar
Agate committed
428
        if id.startswith("al-"):
429
            try:
Agate's avatar
Agate committed
430 431 432 433 434 435
                album_id = int(id.replace("al-", ""))
                album = (
                    music_models.Album.objects.exclude(cover__isnull=True)
                    .exclude(cover="")
                    .get(pk=album_id)
                )
436
            except (TypeError, ValueError, music_models.Album.DoesNotExist):
Agate's avatar
Agate committed
437 438 439
                return response.Response(
                    {"error": {"code": 70, "message": "cover art not found."}}
                )
440 441
            cover = album.cover
        else:
Agate's avatar
Agate committed
442 443 444
            return response.Response(
                {"error": {"code": 70, "message": "cover art not found."}}
            )
445

Agate's avatar
Agate committed
446
        mapping = {"nginx": "X-Accel-Redirect", "apache2": "X-Sendfile"}
447 448 449
        path = music_views.get_file_path(cover)
        file_header = mapping[settings.REVERSE_PROXY_TYPE]
        # let the proxy set the content-type
Agate's avatar
Agate committed
450
        r = response.Response({}, content_type="")
451
        r[file_header] = path
452 453
        return r

Agate's avatar
Agate committed
454
    @list_route(methods=["get", "post"], url_name="scrobble", url_path="scrobble")
455 456 457
    def scrobble(self, request, *args, **kwargs):
        data = request.GET or request.POST
        serializer = serializers.ScrobbleSerializer(
Agate's avatar
Agate committed
458 459
            data=data, context={"user": request.user}
        )
460
        if not serializer.is_valid():
Agate's avatar
Agate committed
461 462 463 464
            return response.Response(
                {"error": {"code": 0, "message": "Invalid payload"}}
            )
        if serializer.validated_data["submission"]:
465 466
            l = serializer.save()
            record.send(l)
467
        return response.Response({})