Skip to content

Commit

Permalink
Add ADRF and make the thumbnail view async
Browse files Browse the repository at this point in the history
This does not convert the `image_proxy.get` to async to avoid making this initial PR more complicated than it needs to be, and to avoid being blocked on the aiohttp client session sharing which we will want to have in before we convert the image proxy to use aiohttp.
  • Loading branch information
sarayourfriend committed Sep 13, 2023
1 parent e8a415c commit 66c95b6
Show file tree
Hide file tree
Showing 8 changed files with 169 additions and 108 deletions.
1 change: 1 addition & 0 deletions api/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ requests-oauthlib = "~=1.3"
sentry-sdk = "~=1.30"
django-split-settings = "*"
uvicorn = {extras = ["standard"], version = "*"}
adrf = "~=0.1.2"

[requires]
python_version = "3.11"
41 changes: 28 additions & 13 deletions api/Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 26 additions & 11 deletions api/api/utils/image_proxy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from dataclasses import asdict, dataclass
from typing import Literal
from urllib.parse import urlparse

Expand Down Expand Up @@ -33,13 +34,25 @@
THUMBNAIL_STRATEGY = Literal["photon_proxy", "original"]


@dataclass
class ImageProxyMediaInfo:
media_identifier: str
image_url: str


@dataclass
class ImageProxyConfig:
accept_header: str = "image/*"
is_full_size: bool = False
is_compressed: bool = True


def get_request_params_for_extension(
ext: str,
headers: dict[str, str],
image_url: str,
parsed_image_url: urlparse,
is_full_size: bool,
is_compressed: bool,
proxy_config: ImageProxyConfig,
) -> tuple[str, dict[str, str], dict[str, str]]:
"""
Get the request params (url, params, headers) for the thumbnail proxy.
Expand All @@ -49,7 +62,10 @@ def get_request_params_for_extension(
"""
if ext in PHOTON_TYPES:
return get_photon_request_params(
parsed_image_url, is_full_size, is_compressed, headers
parsed_image_url,
proxy_config.is_full_size,
proxy_config.is_compressed,
headers,
)
elif ext in ORIGINAL_TYPES:
return image_url, {}, headers
Expand All @@ -59,23 +75,23 @@ def get_request_params_for_extension(


def get(
image_url: str,
media_identifier: str,
accept_header: str = "image/*",
is_full_size: bool = False,
is_compressed: bool = True,
media_info: ImageProxyMediaInfo,
proxy_config: ImageProxyConfig = ImageProxyConfig(),
) -> HttpResponse:
"""
Proxy an image through Photon if its file type is supported, else return the
original image if the file type is SVG. Otherwise, raise an exception.
"""
image_url = media_info.image_url
media_identifier = media_info.media_identifier

logger = parent_logger.getChild("get")
tallies = django_redis.get_redis_connection("tallies")
month = get_monthly_timestamp()

image_extension = get_image_extension(image_url, media_identifier)

headers = {"Accept": accept_header} | HEADERS
headers = {"Accept": proxy_config.accept_header} | HEADERS

parsed_image_url = urlparse(image_url)
domain = parsed_image_url.netloc
Expand All @@ -85,8 +101,7 @@ def get(
headers,
image_url,
parsed_image_url,
is_full_size,
is_compressed,
proxy_config,
)

try:
Expand Down
38 changes: 12 additions & 26 deletions api/api/views/audio_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,17 @@
from drf_spectacular.utils import extend_schema, extend_schema_view

from api.constants.media_types import AUDIO_TYPE
from api.docs.audio_docs import (
detail,
related,
report,
search,
stats,
thumbnail,
waveform,
)
from api.docs.audio_docs import detail, related, report, search, stats
from api.docs.audio_docs import thumbnail as thumbnail_docs
from api.docs.audio_docs import waveform
from api.models import Audio
from api.serializers.audio_serializers import (
AudioReportRequestSerializer,
AudioSearchRequestSerializer,
AudioSerializer,
AudioWaveformSerializer,
)
from api.serializers.media_serializers import MediaThumbnailRequestSerializer
from api.utils.image_proxy import ImageProxyMediaInfo
from api.utils.throttle import AnonThumbnailRateThrottle, OAuth2IdThumbnailRateThrottle
from api.views.media_views import MediaViewSet

Expand All @@ -48,21 +42,8 @@ def get_queryset(self):

# Extra actions

@thumbnail
@action(
detail=True,
url_path="thumb",
url_name="thumb",
serializer_class=MediaThumbnailRequestSerializer,
throttle_classes=[AnonThumbnailRateThrottle, OAuth2IdThumbnailRateThrottle],
)
def thumbnail(self, request, *_, **__):
"""
Retrieve the scaled down and compressed thumbnail of the artwork of an
audio track or its audio set.
"""

audio = self.get_object()
async def get_image_proxy_media_info(self) -> ImageProxyMediaInfo:
audio = await self.aget_object()

image_url = None
if audio_thumbnail := audio.thumbnail:
Expand All @@ -72,7 +53,12 @@ def thumbnail(self, request, *_, **__):
if not image_url:
raise NotFound("Could not find artwork.")

return super().thumbnail(request, audio, image_url)
return ImageProxyMediaInfo(
media_identifier=audio.identifier,
image_url=image_url,
)

thumbnail = thumbnail_docs(MediaViewSet.thumbnail)

@waveform
@action(
Expand Down
34 changes: 10 additions & 24 deletions api/api/views/image_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,8 @@
from PIL import Image as PILImage

from api.constants.media_types import IMAGE_TYPE
from api.docs.image_docs import (
detail,
oembed,
related,
report,
search,
stats,
thumbnail,
)
from api.docs.image_docs import detail, oembed, related, report, search, stats
from api.docs.image_docs import thumbnail as thumbnail_docs
from api.docs.image_docs import watermark as watermark_doc
from api.models import Image
from api.serializers.image_serializers import (
Expand All @@ -31,8 +24,7 @@
OembedSerializer,
WatermarkRequestSerializer,
)
from api.serializers.media_serializers import MediaThumbnailRequestSerializer
from api.utils.throttle import AnonThumbnailRateThrottle, OAuth2IdThumbnailRateThrottle
from api.utils.image_proxy import ImageProxyMediaInfo
from api.utils.watermark import watermark
from api.views.media_views import MediaViewSet

Expand Down Expand Up @@ -99,25 +91,19 @@ def oembed(self, request, *_, **__):
serializer = self.get_serializer(image, context=context)
return Response(data=serializer.data)

@thumbnail
@action(
detail=True,
url_path="thumb",
url_name="thumb",
serializer_class=MediaThumbnailRequestSerializer,
throttle_classes=[AnonThumbnailRateThrottle, OAuth2IdThumbnailRateThrottle],
)
def thumbnail(self, request, *_, **__):
"""Retrieve the scaled down and compressed thumbnail of the image."""

image = self.get_object()
async def get_image_proxy_media_info(self) -> ImageProxyMediaInfo:
image = await self.aget_object()
image_url = image.url
# Hotfix to use thumbnails for SMK images
# TODO: Remove when small thumbnail issues are resolved
if "iip.smk.dk" in image_url and image.thumbnail:
image_url = image.thumbnail

return super().thumbnail(request, image, image_url)
return ImageProxyMediaInfo(
media_identifier=image.identifier, image_url=image_url
)

thumbnail = thumbnail_docs(MediaViewSet.thumbnail)

@watermark_doc
@action(detail=True, url_path="watermark", url_name="watermark")
Expand Down
42 changes: 35 additions & 7 deletions api/api/views/media_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,29 @@
from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet

from adrf.views import APIView as AsyncAPIView
from adrf.viewsets import ViewSetMixin as AsyncViewSetMixin
from asgiref.sync import sync_to_async

from api.controllers import search_controller
from api.models import ContentProvider
from api.models.media import AbstractMedia
from api.serializers.media_serializers import MediaThumbnailRequestSerializer
from api.serializers.provider_serializers import ProviderSerializer
from api.utils import image_proxy
from api.utils.pagination import StandardPagination
from api.utils.throttle import AnonThumbnailRateThrottle, OAuth2IdThumbnailRateThrottle


logger = logging.getLogger(__name__)


class MediaViewSet(ReadOnlyModelViewSet):
image_proxy_aget = sync_to_async(image_proxy.get, thread_sensitive=True)


class MediaViewSet(AsyncViewSetMixin, AsyncAPIView, ReadOnlyModelViewSet):
view_is_async = True

lookup_field = "identifier"
# TODO: https://github.com/encode/django-rest-framework/pull/6789
lookup_value_regex = (
Expand Down Expand Up @@ -58,6 +69,8 @@ def get_queryset(self):
).values_list("provider_identifier")
)

aget_object = sync_to_async(ReadOnlyModelViewSet.get_object)

def get_serializer_context(self):
context = super().get_serializer_context()
req_serializer = self._get_request_serializer(self.request)
Expand Down Expand Up @@ -174,15 +187,30 @@ def report(self, request, identifier):

return Response(data=serializer.data, status=status.HTTP_201_CREATED)

def thumbnail(self, request, media_obj, image_url):
async def get_image_proxy_media_info(self) -> image_proxy.ImageProxyMediaInfo:
raise NotImplementedError(
"Subclasses must implement `get_image_proxy_media_info`"
)

@action(
detail=True,
url_path="thumb",
url_name="thumb",
serializer_class=MediaThumbnailRequestSerializer,
throttle_classes=[AnonThumbnailRateThrottle, OAuth2IdThumbnailRateThrottle],
)
async def thumbnail(self, request, *_, **__):
serializer = self.get_serializer(data=request.query_params)
serializer.is_valid(raise_exception=True)

return image_proxy.get(
image_url,
media_obj.identifier,
accept_header=request.headers.get("Accept", "image/*"),
**serializer.validated_data,
media_info = await self.get_image_proxy_media_info()

return await image_proxy_aget(
media_info,
proxy_config=image_proxy.ImageProxyConfig(
accept_header=request.headers.get("Accept", "image/*"),
**serializer.validated_data,
),
)

# Helper functions
Expand Down
Loading

0 comments on commit 66c95b6

Please sign in to comment.