views.py 20.3 KB
Newer Older
1 2
import datetime

3
from django.conf import settings
4
from django.utils import timezone
5 6
from rest_framework import exceptions
from rest_framework import permissions as rest_permissions
Agate's avatar
Agate committed
7
from rest_framework import renderers, response, viewsets
8 9 10
from rest_framework.decorators import list_route
from rest_framework.serializers import ValidationError

11
import funkwhale_api
12
from funkwhale_api.activity import record
13
from funkwhale_api.common import preferences
14
from funkwhale_api.favorites.models import TrackFavorite
15
from funkwhale_api.music import models as music_models
16
from funkwhale_api.music import utils
17
from funkwhale_api.music import views as music_views
18
from funkwhale_api.playlists import models as playlists_models
Agate's avatar
Agate committed
19
from funkwhale_api.users import models as users_models
20

Agate's avatar
Agate committed
21
from . import authentication, filters, negotiation, serializers
22 23


24 25 26
def find_object(
    queryset, model_field="pk", field="id", cast=int, filter_playable=False
):
27 28 29 30 31 32
    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
33 34 35 36 37 38 39 40
                return response.Response(
                    {
                        "error": {
                            "code": 10,
                            "message": "required parameter '{}' not present".format(
                                field
                            ),
                        }
41
                    }
Agate's avatar
Agate committed
42
                )
43 44
            try:
                value = cast(raw_value)
45
            except (ValueError, TypeError, ValidationError):
Agate's avatar
Agate committed
46 47 48 49 50 51
                return response.Response(
                    {
                        "error": {
                            "code": 0,
                            "message": 'For input string "{}"'.format(raw_value),
                        }
52
                    }
Agate's avatar
Agate committed
53
                )
54
            qs = queryset
Agate's avatar
Agate committed
55
            if hasattr(qs, "__call__"):
56
                qs = qs(request)
57 58 59 60 61

            if filter_playable:
                actor = utils.get_actor_from_request(request)
                qs = qs.playable_by(actor).distinct()

62
            try:
63 64
                obj = qs.get(**{model_field: value})
            except qs.model.DoesNotExist:
Agate's avatar
Agate committed
65 66 67 68 69 70 71 72
                return response.Response(
                    {
                        "error": {
                            "code": 70,
                            "message": "{} not found".format(
                                qs.model.__class__.__name__
                            ),
                        }
73
                    }
Agate's avatar
Agate committed
74 75
                )
            kwargs["obj"] = obj
76
            return func(self, request, *args, **kwargs)
Agate's avatar
Agate committed
77

78
        return inner
Agate's avatar
Agate committed
79

80 81 82 83 84 85 86 87
    return decorator


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

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

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

        return response.Response(payload, status=200)

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

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

Agate's avatar
Agate committed
136
    @list_route(methods=["get", "post"], url_name="get_artists", url_path="getArtists")
137
    def get_artists(self, request, *args, **kwargs):
138 139 140
        artists = music_models.Artist.objects.all().playable_by(
            utils.get_actor_from_request(request)
        )
141
        data = serializers.GetArtistsSerializer(artists).data
Agate's avatar
Agate committed
142
        payload = {"artists": data}
143 144 145

        return response.Response(payload, status=200)

Agate's avatar
Agate committed
146
    @list_route(methods=["get", "post"], url_name="get_indexes", url_path="getIndexes")
147
    def get_indexes(self, request, *args, **kwargs):
148 149 150
        artists = music_models.Artist.objects.all().playable_by(
            utils.get_actor_from_request(request)
        )
151
        data = serializers.GetArtistsSerializer(artists).data
Agate's avatar
Agate committed
152
        payload = {"indexes": data}
153 154 155

        return response.Response(payload, status=200)

Agate's avatar
Agate committed
156
    @list_route(methods=["get", "post"], url_name="get_artist", url_path="getArtist")
157
    @find_object(music_models.Artist.objects.all(), filter_playable=True)
158
    def get_artist(self, request, *args, **kwargs):
Agate's avatar
Agate committed
159
        artist = kwargs.pop("obj")
160
        data = serializers.GetArtistSerializer(artist).data
Agate's avatar
Agate committed
161
        payload = {"artist": data}
162 163 164

        return response.Response(payload, status=200)

165
    @list_route(methods=["get", "post"], url_name="get_song", url_path="getSong")
166
    @find_object(music_models.Track.objects.all(), filter_playable=True)
167 168 169 170 171 172 173
    def get_song(self, request, *args, **kwargs):
        track = kwargs.pop("obj")
        data = serializers.GetSongSerializer(track).data
        payload = {"song": data}

        return response.Response(payload, status=200)

174
    @list_route(
Agate's avatar
Agate committed
175 176
        methods=["get", "post"], url_name="get_artist_info2", url_path="getArtistInfo2"
    )
177
    @find_object(music_models.Artist.objects.all(), filter_playable=True)
178
    def get_artist_info2(self, request, *args, **kwargs):
Agate's avatar
Agate committed
179
        payload = {"artist-info2": {}}
180 181 182

        return response.Response(payload, status=200)

Agate's avatar
Agate committed
183
    @list_route(methods=["get", "post"], url_name="get_album", url_path="getAlbum")
184 185 186
    @find_object(
        music_models.Album.objects.select_related("artist"), filter_playable=True
    )
187
    def get_album(self, request, *args, **kwargs):
Agate's avatar
Agate committed
188
        album = kwargs.pop("obj")
189
        data = serializers.GetAlbumSerializer(album).data
Agate's avatar
Agate committed
190
        payload = {"album": data}
191 192
        return response.Response(payload, status=200)

Agate's avatar
Agate committed
193
    @list_route(methods=["get", "post"], url_name="stream", url_path="stream")
194
    @find_object(music_models.Track.objects.all(), filter_playable=True)
195
    def stream(self, request, *args, **kwargs):
196
        data = request.GET or request.POST
Agate's avatar
Agate committed
197
        track = kwargs.pop("obj")
Agate's avatar
Agate committed
198 199 200
        queryset = track.uploads.select_related("track__album__artist", "track__artist")
        upload = queryset.first()
        if not upload:
201
            return response.Response(status=404)
202

Agate's avatar
Agate committed
203 204
        format = data.get("format", "raw")
        if format == "raw":
205 206
            format = None
        return music_views.handle_serve(upload=upload, user=request.user, format=format)
207

Agate's avatar
Agate committed
208 209
    @list_route(methods=["get", "post"], url_name="star", url_path="star")
    @find_object(music_models.Track.objects.all())
210
    def star(self, request, *args, **kwargs):
Agate's avatar
Agate committed
211
        track = kwargs.pop("obj")
212
        TrackFavorite.add(user=request.user, track=track)
Agate's avatar
Agate committed
213
        return response.Response({"status": "ok"})
214

Agate's avatar
Agate committed
215 216
    @list_route(methods=["get", "post"], url_name="unstar", url_path="unstar")
    @find_object(music_models.Track.objects.all())
217
    def unstar(self, request, *args, **kwargs):
Agate's avatar
Agate committed
218
        track = kwargs.pop("obj")
219
        request.user.track_favorites.filter(track=track).delete()
Agate's avatar
Agate committed
220
        return response.Response({"status": "ok"})
221 222

    @list_route(
Agate's avatar
Agate committed
223 224
        methods=["get", "post"], url_name="get_starred2", url_path="getStarred2"
    )
225 226
    def get_starred2(self, request, *args, **kwargs):
        favorites = request.user.track_favorites.all()
Agate's avatar
Agate committed
227
        data = {"starred2": {"song": serializers.get_starred_tracks_data(favorites)}}
228 229
        return response.Response(data)

230 231 232 233 234 235 236 237 238 239 240 241 242
    @list_route(
        methods=["get", "post"], url_name="get_random_songs", url_path="getRandomSongs"
    )
    def get_random_songs(self, request, *args, **kwargs):
        data = request.GET or request.POST
        actor = utils.get_actor_from_request(request)
        queryset = music_models.Track.objects.all()
        queryset = queryset.playable_by(actor)
        try:
            size = int(data["size"])
        except (TypeError, KeyError, ValueError):
            size = 50

Agate's avatar
Agate committed
243 244 245
        queryset = (
            queryset.playable_by(actor).prefetch_related("uploads").order_by("?")[:size]
        )
246 247 248 249 250 251 252
        data = {
            "randomSongs": {
                "song": serializers.GetSongSerializer(queryset, many=True).data
            }
        }
        return response.Response(data)

Agate's avatar
Agate committed
253
    @list_route(methods=["get", "post"], url_name="get_starred", url_path="getStarred")
254 255
    def get_starred(self, request, *args, **kwargs):
        favorites = request.user.track_favorites.all()
Agate's avatar
Agate committed
256
        data = {"starred": {"song": serializers.get_starred_tracks_data(favorites)}}
257 258 259
        return response.Response(data)

    @list_route(
Agate's avatar
Agate committed
260 261
        methods=["get", "post"], url_name="get_album_list2", url_path="getAlbumList2"
    )
262
    def get_album_list2(self, request, *args, **kwargs):
263 264 265
        queryset = music_models.Album.objects.with_tracks_count().order_by(
            "artist__name"
        )
266 267 268
        data = request.GET or request.POST
        filterset = filters.AlbumList2FilterSet(data, queryset=queryset)
        queryset = filterset.qs
269 270 271
        actor = utils.get_actor_from_request(request)
        queryset = queryset.playable_by(actor)

272
        try:
Agate's avatar
Agate committed
273
            offset = int(data["offset"])
274 275 276 277
        except (TypeError, KeyError, ValueError):
            offset = 0

        try:
Agate's avatar
Agate committed
278
            size = int(data["size"])
279 280 281 282
        except (TypeError, KeyError, ValueError):
            size = 50

        size = min(size, 500)
283
        queryset = queryset[offset : offset + size]
Agate's avatar
Agate committed
284
        data = {"albumList2": {"album": serializers.get_album_list2_data(queryset)}}
285 286
        return response.Response(data)

Agate's avatar
Agate committed
287
    @list_route(methods=["get", "post"], url_name="search3", url_path="search3")
288 289
    def search3(self, request, *args, **kwargs):
        data = request.GET or request.POST
Agate's avatar
Agate committed
290
        query = str(data.get("query", "")).replace("*", "")
291
        actor = utils.get_actor_from_request(request)
292 293
        conf = [
            {
Agate's avatar
Agate committed
294 295 296 297 298 299
                "subsonic": "artist",
                "search_fields": ["name"],
                "queryset": (
                    music_models.Artist.objects.with_albums_count().values(
                        "id", "_albums_count", "name"
                    )
300
                ),
Agate's avatar
Agate committed
301
                "serializer": lambda qs: [serializers.get_artist_data(a) for a in qs],
302 303
            },
            {
Agate's avatar
Agate committed
304 305 306 307 308 309
                "subsonic": "album",
                "search_fields": ["title"],
                "queryset": (
                    music_models.Album.objects.with_tracks_count().select_related(
                        "artist"
                    )
310
                ),
Agate's avatar
Agate committed
311
                "serializer": serializers.get_album_list2_data,
312 313
            },
            {
Agate's avatar
Agate committed
314 315 316
                "subsonic": "song",
                "search_fields": ["title"],
                "queryset": (
Agate's avatar
Agate committed
317 318 319
                    music_models.Track.objects.prefetch_related(
                        "uploads"
                    ).select_related("album__artist")
320
                ),
Agate's avatar
Agate committed
321
                "serializer": serializers.get_song_list_data,
322 323
            },
        ]
Agate's avatar
Agate committed
324
        payload = {"searchResult3": {}}
325
        for c in conf:
Agate's avatar
Agate committed
326 327
            offsetKey = "{}Offset".format(c["subsonic"])
            countKey = "{}Count".format(c["subsonic"])
328 329 330 331 332 333 334 335 336 337 338
            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
339
            queryset = c["queryset"]
340
            if query:
Agate's avatar
Agate committed
341 342
                queryset = c["queryset"].filter(
                    utils.get_query(query, c["search_fields"])
343
                )
344
            queryset = queryset.playable_by(actor)
345
            queryset = queryset[offset : offset + size]
Agate's avatar
Agate committed
346
            payload["searchResult3"][c["subsonic"]] = c["serializer"](queryset)
347
        return response.Response(payload)
348 349

    @list_route(
Agate's avatar
Agate committed
350 351
        methods=["get", "post"], url_name="get_playlists", url_path="getPlaylists"
    )
352
    def get_playlists(self, request, *args, **kwargs):
Agate's avatar
Agate committed
353
        playlists = request.user.playlists.with_tracks_count().select_related("user")
354
        data = {
Agate's avatar
Agate committed
355 356
            "playlists": {
                "playlist": [serializers.get_playlist_data(p) for p in playlists]
357 358 359 360 361
            }
        }
        return response.Response(data)

    @list_route(
Agate's avatar
Agate committed
362 363 364
        methods=["get", "post"], url_name="get_playlist", url_path="getPlaylist"
    )
    @find_object(playlists_models.Playlist.objects.with_tracks_count())
365
    def get_playlist(self, request, *args, **kwargs):
Agate's avatar
Agate committed
366 367
        playlist = kwargs.pop("obj")
        data = {"playlist": serializers.get_playlist_detail_data(playlist)}
368 369 370
        return response.Response(data)

    @list_route(
Agate's avatar
Agate committed
371 372 373
        methods=["get", "post"], url_name="update_playlist", url_path="updatePlaylist"
    )
    @find_object(lambda request: request.user.playlists.all(), field="playlistId")
374
    def update_playlist(self, request, *args, **kwargs):
Agate's avatar
Agate committed
375
        playlist = kwargs.pop("obj")
376
        data = request.GET or request.POST
Agate's avatar
Agate committed
377
        new_name = data.get("name", "")
378 379
        if new_name:
            playlist.name = new_name
Agate's avatar
Agate committed
380
            playlist.save(update_fields=["name", "modification_date"])
381
        try:
Agate's avatar
Agate committed
382
            to_remove = int(data["songIndexToRemove"])
383 384 385 386 387 388 389 390
            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)

391
        ids = []
Agate's avatar
Agate committed
392
        for i in data.getlist("songIdToAdd"):
393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408
            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
409
        data = {"status": "ok"}
410 411 412
        return response.Response(data)

    @list_route(
Agate's avatar
Agate committed
413 414 415
        methods=["get", "post"], url_name="delete_playlist", url_path="deletePlaylist"
    )
    @find_object(lambda request: request.user.playlists.all())
416
    def delete_playlist(self, request, *args, **kwargs):
Agate's avatar
Agate committed
417
        playlist = kwargs.pop("obj")
418
        playlist.delete()
Agate's avatar
Agate committed
419
        data = {"status": "ok"}
420 421 422
        return response.Response(data)

    @list_route(
Agate's avatar
Agate committed
423 424
        methods=["get", "post"], url_name="create_playlist", url_path="createPlaylist"
    )
425 426
    def create_playlist(self, request, *args, **kwargs):
        data = request.GET or request.POST
Agate's avatar
Agate committed
427
        name = data.get("name", "")
428
        if not name:
Agate's avatar
Agate committed
429 430 431 432 433 434
            return response.Response(
                {
                    "error": {
                        "code": 10,
                        "message": "Playlist ID or name must be specified.",
                    }
435
                }
Agate's avatar
Agate committed
436
            )
437

Agate's avatar
Agate committed
438
        playlist = request.user.playlists.create(name=name)
439
        ids = []
Agate's avatar
Agate committed
440
        for i in data.getlist("songId"):
441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456
            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
457 458
        playlist = request.user.playlists.with_tracks_count().get(pk=playlist.pk)
        data = {"playlist": serializers.get_playlist_detail_data(playlist)}
459
        return response.Response(data)
460

Agate's avatar
Agate committed
461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477
    @list_route(methods=["get", "post"], url_name="get_avatar", url_path="getAvatar")
    @find_object(
        queryset=users_models.User.objects.exclude(avatar=None).exclude(avatar=""),
        model_field="username__iexact",
        field="username",
        cast=str,
    )
    def get_avatar(self, request, *args, **kwargs):
        user = kwargs.pop("obj")
        mapping = {"nginx": "X-Accel-Redirect", "apache2": "X-Sendfile"}
        path = music_views.get_file_path(user.avatar)
        file_header = mapping[settings.REVERSE_PROXY_TYPE]
        # let the proxy set the content-type
        r = response.Response({}, content_type="")
        r[file_header] = path
        return r

Agate's avatar
Agate committed
478 479 480 481 482 483 484 485 486 487 488
    @list_route(methods=["get", "post"], url_name="get_user", url_path="getUser")
    @find_object(
        queryset=lambda request: users_models.User.objects.filter(pk=request.user.pk),
        model_field="username__iexact",
        field="username",
        cast=str,
    )
    def get_user(self, request, *args, **kwargs):
        data = {"user": serializers.get_user_detail_data(request.user)}
        return response.Response(data)

489
    @list_route(
Agate's avatar
Agate committed
490 491 492 493
        methods=["get", "post"],
        url_name="get_music_folders",
        url_path="getMusicFolders",
    )
494
    def get_music_folders(self, request, *args, **kwargs):
Agate's avatar
Agate committed
495
        data = {"musicFolders": {"musicFolder": [{"id": 1, "name": "Music"}]}}
496
        return response.Response(data)
497 498

    @list_route(
Agate's avatar
Agate committed
499 500
        methods=["get", "post"], url_name="get_cover_art", url_path="getCoverArt"
    )
501 502
    def get_cover_art(self, request, *args, **kwargs):
        data = request.GET or request.POST
Agate's avatar
Agate committed
503
        id = data.get("id", "")
504
        if not id:
Agate's avatar
Agate committed
505 506 507
            return response.Response(
                {"error": {"code": 10, "message": "cover art ID must be specified."}}
            )
508

Agate's avatar
Agate committed
509
        if id.startswith("al-"):
510
            try:
Agate's avatar
Agate committed
511 512 513 514 515 516
                album_id = int(id.replace("al-", ""))
                album = (
                    music_models.Album.objects.exclude(cover__isnull=True)
                    .exclude(cover="")
                    .get(pk=album_id)
                )
517
            except (TypeError, ValueError, music_models.Album.DoesNotExist):
Agate's avatar
Agate committed
518 519 520
                return response.Response(
                    {"error": {"code": 70, "message": "cover art not found."}}
                )
521 522
            cover = album.cover
        else:
Agate's avatar
Agate committed
523 524 525
            return response.Response(
                {"error": {"code": 70, "message": "cover art not found."}}
            )
526

Agate's avatar
Agate committed
527
        mapping = {"nginx": "X-Accel-Redirect", "apache2": "X-Sendfile"}
528 529 530
        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
531
        r = response.Response({}, content_type="")
532
        r[file_header] = path
533 534
        return r

Agate's avatar
Agate committed
535
    @list_route(methods=["get", "post"], url_name="scrobble", url_path="scrobble")
536 537 538
    def scrobble(self, request, *args, **kwargs):
        data = request.GET or request.POST
        serializer = serializers.ScrobbleSerializer(
Agate's avatar
Agate committed
539 540
            data=data, context={"user": request.user}
        )
541
        if not serializer.is_valid():
Agate's avatar
Agate committed
542 543 544 545
            return response.Response(
                {"error": {"code": 0, "message": "Invalid payload"}}
            )
        if serializer.validated_data["submission"]:
546 547
            listening = serializer.save()
            record.send(listening)
548
        return response.Response({})