views.py 19.6 KB
Newer Older
1
import logging
2
import urllib
3

4
from django.conf import settings
5
from django.db import transaction
6
from django.db.models import Count, Prefetch, Sum, F, Q
Eliot Berriot's avatar
Eliot Berriot committed
7
from django.db.models.functions import Length
8
from django.utils import timezone
9

Eliot Berriot's avatar
Eliot Berriot committed
10
11
12
from rest_framework import mixins
from rest_framework import settings as rest_settings
from rest_framework import views, viewsets
Eliot Berriot's avatar
Eliot Berriot committed
13
from rest_framework.decorators import action
14
from rest_framework.response import Response
Eliot Berriot's avatar
Eliot Berriot committed
15
from taggit.models import Tag
16

17
from funkwhale_api.common import decorators as common_decorators
18
from funkwhale_api.common import permissions as common_permissions
19
20
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils
21
from funkwhale_api.common import views as common_views
22
from funkwhale_api.federation.authentication import SignatureAuthentication
23
from funkwhale_api.federation import actors
24
from funkwhale_api.federation import api_serializers as federation_api_serializers
25
from funkwhale_api.federation import decorators as federation_decorators
Eliot Berriot's avatar
Eliot Berriot committed
26
from funkwhale_api.federation import routes
27
from funkwhale_api.users.oauth import permissions as oauth_permissions
28

Eliot Berriot's avatar
Eliot Berriot committed
29
from . import filters, licenses, models, serializers, tasks, utils
30

31
32
logger = logging.getLogger(__name__)

Eliot Berriot's avatar
Eliot Berriot committed
33

34
def get_libraries(filter_uploads):
Eliot Berriot's avatar
Eliot Berriot committed
35
    def libraries(self, request, *args, **kwargs):
36
37
38
39
40
        obj = self.get_object()
        actor = utils.get_actor_from_request(request)
        uploads = models.Upload.objects.all()
        uploads = filter_uploads(obj, uploads)
        uploads = uploads.playable_by(actor)
Eliot Berriot's avatar
Eliot Berriot committed
41
        qs = models.Library.objects.filter(
42
            pk__in=uploads.values_list("library", flat=True)
43
        ).annotate(_uploads_count=Count("uploads"))
Eliot Berriot's avatar
Eliot Berriot committed
44
45
        qs = qs.select_related("actor")
        page = self.paginate_queryset(qs)
46
47
48
49
        if page is not None:
            serializer = federation_api_serializers.LibrarySerializer(page, many=True)
            return self.get_paginated_response(serializer.data)

Eliot Berriot's avatar
Eliot Berriot committed
50
        serializer = federation_api_serializers.LibrarySerializer(qs, many=True)
51
52
        return Response(serializer.data)

Eliot Berriot's avatar
Eliot Berriot committed
53
    return libraries
54
55


56
57
58
class TagViewSetMixin(object):
    def get_queryset(self):
        queryset = super().get_queryset()
Eliot Berriot's avatar
Eliot Berriot committed
59
        tag = self.request.query_params.get("tag")
60
61
62
63
        if tag:
            queryset = queryset.filter(tags__pk=tag)
        return queryset

64

65
class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet):
66
    queryset = models.Artist.objects.all()
67
    serializer_class = serializers.ArtistWithAlbumsSerializer
68
69
70
    permission_classes = [oauth_permissions.ScopePermission]
    required_scope = "libraries"
    anonymous_policy = "setting"
71
    filterset_class = filters.ArtistFilter
Eliot Berriot's avatar
Eliot Berriot committed
72
    ordering_fields = ("id", "name", "creation_date")
73

74
    fetches = federation_decorators.fetches_route()
Eliot Berriot's avatar
Eliot Berriot committed
75
76
    mutations = common_decorators.mutations_route(types=["update"])

77
78
79
    def get_queryset(self):
        queryset = super().get_queryset()
        albums = models.Album.objects.with_tracks_count()
Eliot Berriot's avatar
Eliot Berriot committed
80
81
82
        albums = albums.annotate_playable_by_actor(
            utils.get_actor_from_request(self.request)
        )
83
        return queryset.prefetch_related(Prefetch("albums", queryset=albums))
84

Eliot Berriot's avatar
Eliot Berriot committed
85
    libraries = action(methods=["get"], detail=True)(
86
87
88
89
90
91
92
        get_libraries(
            filter_uploads=lambda o, uploads: uploads.filter(
                Q(track__artist=o) | Q(track__album__artist=o)
            )
        )
    )

93

94
class AlbumViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet):
95
    queryset = (
96
        models.Album.objects.all().order_by("artist", "release_date").select_related()
Eliot Berriot's avatar
Eliot Berriot committed
97
    )
98
    serializer_class = serializers.AlbumSerializer
99
100
101
    permission_classes = [oauth_permissions.ScopePermission]
    required_scope = "libraries"
    anonymous_policy = "setting"
Eliot Berriot's avatar
Eliot Berriot committed
102
    ordering_fields = ("creation_date", "release_date", "title")
103
    filterset_class = filters.AlbumFilter
104

105
    fetches = federation_decorators.fetches_route()
Eliot Berriot's avatar
Eliot Berriot committed
106
107
    mutations = common_decorators.mutations_route(types=["update"])

108
109
    def get_queryset(self):
        queryset = super().get_queryset()
110
111
112
113
        tracks = (
            models.Track.objects.select_related("artist")
            .with_playable_uploads(utils.get_actor_from_request(self.request))
            .order_for_album()
114
        )
115
        qs = queryset.prefetch_related(Prefetch("tracks", queryset=tracks))
116
        return qs
117

Eliot Berriot's avatar
Eliot Berriot committed
118
    libraries = action(methods=["get"], detail=True)(
119
120
121
        get_libraries(filter_uploads=lambda o, uploads: uploads.filter(track__album=o))
    )

122

123
class LibraryViewSet(
Eliot Berriot's avatar
Eliot Berriot committed
124
125
126
    mixins.CreateModelMixin,
    mixins.ListModelMixin,
    mixins.RetrieveModelMixin,
127
128
    mixins.UpdateModelMixin,
    mixins.DestroyModelMixin,
Eliot Berriot's avatar
Eliot Berriot committed
129
130
    viewsets.GenericViewSet,
):
131
    lookup_field = "uuid"
132
    queryset = (
133
        models.Library.objects.all()
Eliot Berriot's avatar
Eliot Berriot committed
134
        .order_by("-creation_date")
Eliot Berriot's avatar
Eliot Berriot committed
135
136
        .annotate(_uploads_count=Count("uploads"))
        .annotate(_size=Sum("uploads__size"))
137
    )
138
139
    serializer_class = serializers.LibraryForOwnerSerializer
    permission_classes = [
140
        oauth_permissions.ScopePermission,
141
142
        common_permissions.OwnerPermission,
    ]
143
144
    required_scope = "libraries"
    anonymous_policy = "setting"
145
146
    owner_field = "actor.user"
    owner_checks = ["read", "write"]
Eliot Berriot's avatar
Eliot Berriot committed
147

148
149
    def get_queryset(self):
        qs = super().get_queryset()
150
        return qs.filter(actor=self.request.user.actor)
151

Eliot Berriot's avatar
Eliot Berriot committed
152
    def perform_create(self, serializer):
153
        serializer.save(actor=self.request.user.actor)
Eliot Berriot's avatar
Eliot Berriot committed
154

Eliot Berriot's avatar
Eliot Berriot committed
155
156
157
158
159
160
161
162
    @transaction.atomic
    def perform_destroy(self, instance):
        routes.outbox.dispatch(
            {"type": "Delete", "object": {"type": "Library"}},
            context={"library": instance},
        )
        instance.delete()

Eliot Berriot's avatar
Eliot Berriot committed
163
164
165
    follows = action

    @action(methods=["get"], detail=True)
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
    @transaction.non_atomic_requests
    def follows(self, request, *args, **kwargs):
        library = self.get_object()
        queryset = (
            library.received_follows.filter(target__actor=self.request.user.actor)
            .select_related("actor", "target__actor")
            .order_by("-creation_date")
        )
        page = self.paginate_queryset(queryset)
        if page is not None:
            serializer = federation_api_serializers.LibraryFollowSerializer(
                page, many=True
            )
            return self.get_paginated_response(serializer.data)

        serializer = self.get_serializer(queryset, many=True)
        return Response(serializer.data)

184

185
186
187
class TrackViewSet(
    common_views.SkipFilterForGetObject, TagViewSetMixin, viewsets.ReadOnlyModelViewSet
):
188
189
190
    """
    A simple ViewSet for viewing and editing accounts.
    """
Eliot Berriot's avatar
Eliot Berriot committed
191
192

    queryset = models.Track.objects.all().for_nested_serialization()
193
    serializer_class = serializers.TrackSerializer
194
195
196
    permission_classes = [oauth_permissions.ScopePermission]
    required_scope = "libraries"
    anonymous_policy = "setting"
197
    filterset_class = filters.TrackFilter
198
    ordering_fields = (
Eliot Berriot's avatar
Eliot Berriot committed
199
200
201
        "creation_date",
        "title",
        "album__release_date",
202
        "size",
203
204
        "position",
        "disc_number",
Eliot Berriot's avatar
Eliot Berriot committed
205
        "artist__name",
206
    )
207
    fetches = federation_decorators.fetches_route()
208
209
    mutations = common_decorators.mutations_route(types=["update"])

210
211
    def get_queryset(self):
        queryset = super().get_queryset()
Eliot Berriot's avatar
Eliot Berriot committed
212
        filter_favorites = self.request.GET.get("favorites", None)
213
        user = self.request.user
Eliot Berriot's avatar
Eliot Berriot committed
214
        if user.is_authenticated and filter_favorites == "true":
215
216
            queryset = queryset.filter(track_favorites__user=user)

217
        queryset = queryset.with_playable_uploads(
218
            utils.get_actor_from_request(self.request)
219
220
        )
        return queryset
221

Eliot Berriot's avatar
Eliot Berriot committed
222
    libraries = action(methods=["get"], detail=True)(
223
224
225
        get_libraries(filter_uploads=lambda o, uploads: uploads.filter(track=o))
    )

226

227
def get_file_path(audio_file):
228
229
    serve_path = settings.MUSIC_DIRECTORY_SERVE_PATH
    prefix = settings.MUSIC_DIRECTORY_PATH
230
    t = settings.REVERSE_PROXY_TYPE
Eliot Berriot's avatar
Eliot Berriot committed
231
    if t == "nginx":
232
233
234
235
236
        # we have to use the internal locations
        try:
            path = audio_file.url
        except AttributeError:
            # a path was given
237
238
            if not serve_path or not prefix:
                raise ValueError(
Eliot Berriot's avatar
Eliot Berriot committed
239
240
                    "You need to specify MUSIC_DIRECTORY_SERVE_PATH and "
                    "MUSIC_DIRECTORY_PATH to serve in-place imported files"
241
                )
Eliot Berriot's avatar
Eliot Berriot committed
242
            path = "/music" + audio_file.replace(prefix, "", 1)
243
244
        if path.startswith("http://") or path.startswith("https://"):
            return (settings.PROTECT_FILES_PATH + "/media/" + path).encode("utf-8")
Eliot Berriot's avatar
Eliot Berriot committed
245
246
        return (settings.PROTECT_FILES_PATH + path).encode("utf-8")
    if t == "apache2":
247
248
249
250
        try:
            path = audio_file.path
        except AttributeError:
            # a path was given
251
252
            if not serve_path or not prefix:
                raise ValueError(
Eliot Berriot's avatar
Eliot Berriot committed
253
254
                    "You need to specify MUSIC_DIRECTORY_SERVE_PATH and "
                    "MUSIC_DIRECTORY_PATH to serve in-place imported files"
255
                )
256
            path = audio_file.replace(prefix, serve_path, 1)
Eliot Berriot's avatar
Eliot Berriot committed
257
        return path.encode("utf-8")
258
259


260
def should_transcode(upload, format, max_bitrate=None):
261
262
    if not preferences.get("music__transcoding_enabled"):
        return False
263
264
    format_need_transcoding = True
    bitrate_need_transcoding = True
265
    if format is None:
266
267
        format_need_transcoding = False
    elif format not in utils.EXTENSION_TO_MIMETYPE:
268
        # format should match supported formats
269
270
        format_need_transcoding = False
    elif upload.mimetype is None:
271
        # upload should have a mimetype, otherwise we cannot transcode
272
273
        format_need_transcoding = False
    elif upload.mimetype == utils.EXTENSION_TO_MIMETYPE[format]:
274
275
        # requested format sould be different than upload mimetype, otherwise
        # there is no need to transcode
276
277
278
279
280
281
282
283
        format_need_transcoding = False

    if max_bitrate is None:
        bitrate_need_transcoding = False
    elif not upload.bitrate:
        bitrate_need_transcoding = False
    elif upload.bitrate <= max_bitrate:
        bitrate_need_transcoding = False
284

285
    return format_need_transcoding or bitrate_need_transcoding
286

287
288

def handle_serve(upload, user, format=None, max_bitrate=None):
Eliot Berriot's avatar
Eliot Berriot committed
289
    f = upload
290
    # we update the accessed_date
291
292
293
294
    now = timezone.now()
    upload.accessed_date = now
    upload.save(update_fields=["accessed_date"])
    f = upload
295
296
297
298
299
300
301
302
303
304
305
306
307
308
    if f.audio_file:
        file_path = get_file_path(f.audio_file)

    elif f.source and (
        f.source.startswith("http://") or f.source.startswith("https://")
    ):
        # 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 = f.__class__.objects.select_for_update()
            f = qs.get(pk=f.pk)
309
310
311
312
313
            if user.is_authenticated:
                actor = user.actor
            else:
                actor = actors.get_service_actor()
            f.download_audio_from_remote(actor=actor)
314
315
316
317
318
319
320
        data = f.get_audio_data()
        if data:
            f.duration = data["duration"]
            f.size = data["size"]
            f.bitrate = data["bitrate"]
            f.save(update_fields=["bitrate", "duration", "size"])
        file_path = get_file_path(f.audio_file)
Eliot Berriot's avatar
Eliot Berriot committed
321
322
    elif f.source and f.source.startswith("file://"):
        file_path = get_file_path(f.source.replace("file://", "", 1))
323
    mt = f.mimetype
324

325
    if should_transcode(f, format, max_bitrate=max_bitrate):
326
        transcoded_version = f.get_transcoded_version(format, max_bitrate=max_bitrate)
327
328
329
330
331
        transcoded_version.accessed_date = now
        transcoded_version.save(update_fields=["accessed_date"])
        f = transcoded_version
        file_path = get_file_path(f.audio_file)
        mt = f.mimetype
332
333
334
335
    if mt:
        response = Response(content_type=mt)
    else:
        response = Response()
336
    filename = f.filename
Eliot Berriot's avatar
Eliot Berriot committed
337
    mapping = {"nginx": "X-Accel-Redirect", "apache2": "X-Sendfile"}
338
339
    file_header = mapping[settings.REVERSE_PROXY_TYPE]
    response[file_header] = file_path
Eliot Berriot's avatar
Eliot Berriot committed
340
    filename = "filename*=UTF-8''{}".format(urllib.parse.quote(filename))
341
342
343
344
345
346
347
    response["Content-Disposition"] = "attachment; {}".format(filename)
    if mt:
        response["Content-Type"] = mt

    return response


348
349
350
class ListenViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
    queryset = models.Track.objects.all()
    serializer_class = serializers.TrackSerializer
Eliot Berriot's avatar
Eliot Berriot committed
351
352
353
354
    authentication_classes = (
        rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES
        + [SignatureAuthentication]
    )
355
356
357
    permission_classes = [oauth_permissions.ScopePermission]
    required_scope = "libraries"
    anonymous_policy = "setting"
358
359
360
361
362
    lookup_field = "uuid"

    def retrieve(self, request, *args, **kwargs):
        track = self.get_object()
        actor = utils.get_actor_from_request(request)
Eliot Berriot's avatar
Eliot Berriot committed
363
364
        queryset = track.uploads.select_related("track__album__artist", "track__artist")
        explicit_file = request.GET.get("upload")
365
366
367
        if explicit_file:
            queryset = queryset.filter(uuid=explicit_file)
        queryset = queryset.playable_by(actor)
Eliot Berriot's avatar
Eliot Berriot committed
368
369
370
        queryset = queryset.order_by(F("audio_file").desc(nulls_last=True))
        upload = queryset.first()
        if not upload:
371
            return Response(status=404)
372

373
        format = request.GET.get("to")
374
375
376
377
378
379
380
381
382
383
384
        max_bitrate = request.GET.get("max_bitrate")
        try:
            max_bitrate = min(max(int(max_bitrate), 0), 320) or None
        except (TypeError, ValueError):
            max_bitrate = None

        if max_bitrate:
            max_bitrate = max_bitrate * 1000
        return handle_serve(
            upload, user=request.user, format=format, max_bitrate=max_bitrate
        )
385
386


Eliot Berriot's avatar
Eliot Berriot committed
387
class UploadViewSet(
388
389
390
391
392
393
394
395
    mixins.ListModelMixin,
    mixins.CreateModelMixin,
    mixins.RetrieveModelMixin,
    mixins.DestroyModelMixin,
    viewsets.GenericViewSet,
):
    lookup_field = "uuid"
    queryset = (
Eliot Berriot's avatar
Eliot Berriot committed
396
        models.Upload.objects.all()
397
398
399
        .order_by("-creation_date")
        .select_related("library", "track__artist", "track__album__artist")
    )
Eliot Berriot's avatar
Eliot Berriot committed
400
    serializer_class = serializers.UploadForOwnerSerializer
401
    permission_classes = [
402
        oauth_permissions.ScopePermission,
403
404
        common_permissions.OwnerPermission,
    ]
405
406
    required_scope = "libraries"
    anonymous_policy = "setting"
407
408
    owner_field = "library.actor.user"
    owner_checks = ["read", "write"]
409
    filterset_class = filters.UploadFilter
410
411
412
413
414
415
416
417
418
419
420
421
    ordering_fields = (
        "creation_date",
        "import_date",
        "bitrate",
        "size",
        "artist__name",
    )

    def get_queryset(self):
        qs = super().get_queryset()
        return qs.filter(library__actor=self.request.user.actor)

Eliot Berriot's avatar
Eliot Berriot committed
422
    @action(methods=["post"], detail=False)
423
424
    def action(self, request, *args, **kwargs):
        queryset = self.get_queryset()
Eliot Berriot's avatar
Eliot Berriot committed
425
        serializer = serializers.UploadActionSerializer(request.data, queryset=queryset)
426
427
428
429
430
431
432
433
434
435
        serializer.is_valid(raise_exception=True)
        result = serializer.save()
        return Response(result, status=200)

    def get_serializer_context(self):
        context = super().get_serializer_context()
        context["user"] = self.request.user
        return context

    def perform_create(self, serializer):
Eliot Berriot's avatar
Eliot Berriot committed
436
        upload = serializer.save()
437
        common_utils.on_commit(tasks.process_upload.delay, upload_id=upload.pk)
Eliot Berriot's avatar
Eliot Berriot committed
438
439
440
441
442
443
444
445

    @transaction.atomic
    def perform_destroy(self, instance):
        routes.outbox.dispatch(
            {"type": "Delete", "object": {"type": "Audio"}},
            context={"uploads": [instance]},
        )
        instance.delete()
446
447


448
class TagViewSet(viewsets.ReadOnlyModelViewSet):
Eliot Berriot's avatar
Eliot Berriot committed
449
    queryset = Tag.objects.all().order_by("name")
450
    serializer_class = serializers.TagSerializer
451
452
453
    permission_classes = [oauth_permissions.ScopePermission]
    required_scope = "libraries"
    anonymous_policy = "setting"
454
455
456
457


class Search(views.APIView):
    max_results = 3
458
459
460
    permission_classes = [oauth_permissions.ScopePermission]
    required_scope = "libraries"
    anonymous_policy = "setting"
461

462
    def get(self, request, *args, **kwargs):
Eliot Berriot's avatar
Eliot Berriot committed
463
        query = request.GET["query"]
464
        results = {
465
            # 'tags': serializers.TagSerializer(self.get_tags(query), many=True).data,
Eliot Berriot's avatar
Eliot Berriot committed
466
467
468
469
470
471
472
473
474
            "artists": serializers.ArtistWithAlbumsSerializer(
                self.get_artists(query), many=True
            ).data,
            "tracks": serializers.TrackSerializer(
                self.get_tracks(query), many=True
            ).data,
            "albums": serializers.AlbumSerializer(
                self.get_albums(query), many=True
            ).data,
475
476
477
478
        }
        return Response(results, status=200)

    def get_tracks(self, query):
479
        search_fields = [
Eliot Berriot's avatar
Eliot Berriot committed
480
481
482
483
484
            "mbid",
            "title__unaccent",
            "album__title__unaccent",
            "artist__name__unaccent",
        ]
485
        query_obj = utils.get_query(query, search_fields)
486
        qs = (
487
            models.Track.objects.all()
Eliot Berriot's avatar
Eliot Berriot committed
488
489
            .filter(query_obj)
            .select_related("artist", "album__artist")
490
491
        )
        return common_utils.order_for_search(qs, "title")[: self.max_results]
492
493

    def get_albums(self, query):
Eliot Berriot's avatar
Eliot Berriot committed
494
        search_fields = ["mbid", "title__unaccent", "artist__name__unaccent"]
495
        query_obj = utils.get_query(query, search_fields)
496
        qs = (
497
            models.Album.objects.all()
Eliot Berriot's avatar
Eliot Berriot committed
498
499
            .filter(query_obj)
            .select_related()
500
501
502
            .prefetch_related("tracks__artist")
        )
        return common_utils.order_for_search(qs, "title")[: self.max_results]
503
504

    def get_artists(self, query):
Eliot Berriot's avatar
Eliot Berriot committed
505
        search_fields = ["mbid", "name__unaccent"]
506
        query_obj = utils.get_query(query, search_fields)
507
508
        qs = models.Artist.objects.all().filter(query_obj).with_albums()
        return common_utils.order_for_search(qs, "name")[: self.max_results]
509
510

    def get_tags(self, query):
Eliot Berriot's avatar
Eliot Berriot committed
511
        search_fields = ["slug", "name__unaccent"]
512
513
514
        query_obj = utils.get_query(query, search_fields)

        # We want the shortest tag first
Eliot Berriot's avatar
Eliot Berriot committed
515
516
517
518
519
        qs = (
            Tag.objects.all()
            .annotate(slug_length=Length("slug"))
            .order_by("slug_length")
        )
520

Eliot Berriot's avatar
Eliot Berriot committed
521
        return qs.filter(query_obj)[: self.max_results]
Eliot Berriot's avatar
Eliot Berriot committed
522
523
524


class LicenseViewSet(viewsets.ReadOnlyModelViewSet):
525
526
527
    permission_classes = [oauth_permissions.ScopePermission]
    required_scope = "libraries"
    anonymous_policy = "setting"
Eliot Berriot's avatar
Eliot Berriot committed
528
529
530
    serializer_class = serializers.LicenseSerializer
    queryset = models.License.objects.all().order_by("code")
    lookup_value_regex = ".*"
Eliot Berriot's avatar
Eliot Berriot committed
531
    max_page_size = 1000
Eliot Berriot's avatar
Eliot Berriot committed
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549

    def get_queryset(self):
        # ensure our licenses are up to date in DB
        licenses.load(licenses.LICENSES)
        return super().get_queryset()

    def get_serializer(self, *args, **kwargs):
        if len(args) == 0:
            return super().get_serializer(*args, **kwargs)

        # our serializer works with license dict, not License instances
        # so we pass those instead
        instance_or_qs = args[0]
        try:
            first_arg = instance_or_qs.conf
        except AttributeError:
            first_arg = [i.conf for i in instance_or_qs if i.conf]
        return super().get_serializer(*((first_arg,) + args[1:]), **kwargs)
550
551
552


class OembedView(views.APIView):
553
554
555
    permission_classes = [oauth_permissions.ScopePermission]
    required_scope = "libraries"
    anonymous_policy = "setting"
556
557
558
559
560
561

    def get(self, request, *args, **kwargs):
        serializer = serializers.OembedSerializer(data=request.GET)
        serializer.is_valid(raise_exception=True)
        embed_data = serializer.save()
        return Response(embed_data)