diff --git a/load_sample_data.sh b/load_sample_data.sh index f1764047b..81da388d7 100755 --- a/load_sample_data.sh +++ b/load_sample_data.sh @@ -56,7 +56,7 @@ docker-compose exec -T "$UPSTREAM_DB_SERVICE_NAME" /bin/bash -c "psql -U deploy docker-compose exec -T "$UPSTREAM_DB_SERVICE_NAME" /bin/bash -c "PGPASSWORD=deploy pg_dump -s -t audio -U deploy -d openledger -h db | head -n -14 | psql -U deploy -d openledger" docker-compose exec -T "$UPSTREAM_DB_SERVICE_NAME" /bin/bash -c "psql -U deploy -d openledger <<-EOF ALTER TABLE audio RENAME TO audio_view; - ALTER TABLE audio_view ADD COLUMN standardized_popularity double precision, ADD COLUMN ingestion_type varchar(1000), ADD COLUMN audio_set jsonb, ADD COLUMN thumbnail varchar(1000); + ALTER TABLE audio_view ADD COLUMN standardized_popularity double precision, ADD COLUMN ingestion_type varchar(1000), ADD COLUMN audio_set jsonb; \copy audio_view (identifier,created_on,updated_on,ingestion_type,provider,source,foreign_identifier,foreign_landing_url,url,thumbnail,duration,bit_rate,sample_rate,category,genres,audio_set,alt_files,filesize,license,license_version,creator,creator_url,title,meta_data,tags,watermarked,last_synced_with_source,removed_from_source,standardized_popularity) from './sample_data/sample_audio_data.csv' with (FORMAT csv, HEADER true) EOF" diff --git a/openverse-api/catalog/api/migrations/0037_media_thumbnails.py b/openverse-api/catalog/api/migrations/0037_media_thumbnails.py new file mode 100644 index 000000000..c3c1dbd21 --- /dev/null +++ b/openverse-api/catalog/api/migrations/0037_media_thumbnails.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.6 on 2021-08-23 11:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0036_foreign_id_uniq'), + ] + + operations = [ + migrations.AddField( + model_name='audio', + name='thumbnail', + field=models.URLField(blank=True, help_text='The thumbnail for the media.', max_length=1000, null=True), + ), + migrations.AlterField( + model_name='image', + name='thumbnail', + field=models.URLField(blank=True, help_text='The thumbnail for the media.', max_length=1000, null=True), + ), + ] diff --git a/openverse-api/catalog/api/models/__init__.py b/openverse-api/catalog/api/models/__init__.py index 736190b0b..dced078af 100644 --- a/openverse-api/catalog/api/models/__init__.py +++ b/openverse-api/catalog/api/models/__init__.py @@ -13,6 +13,7 @@ MatureAudio, AudioList, AltAudioFile, + AudioSet, ) from catalog.api.models.media import ( PENDING, diff --git a/openverse-api/catalog/api/models/image.py b/openverse-api/catalog/api/models/image.py index 4a335584c..344fd8b87 100644 --- a/openverse-api/catalog/api/models/image.py +++ b/openverse-api/catalog/api/models/image.py @@ -12,13 +12,6 @@ class Image(AbstractMedia): - thumbnail = models.URLField( - max_length=1000, - blank=True, - null=True, - help_text="The thumbnail for the image, if any." - ) - width = models.IntegerField(blank=True, null=True) height = models.IntegerField(blank=True, null=True) diff --git a/openverse-api/catalog/api/models/media.py b/openverse-api/catalog/api/models/media.py index 8e88dac0f..1c9d00dad 100644 --- a/openverse-api/catalog/api/models/media.py +++ b/openverse-api/catalog/api/models/media.py @@ -27,6 +27,13 @@ class AbstractMedia(IdentifierMixin, MediaMixin, FileMixin, OpenLedgerModel): information common to all media types indexed by Openverse. """ + thumbnail = models.URLField( + max_length=1000, + blank=True, + null=True, + help_text="The thumbnail for the media." + ) + watermarked = models.BooleanField(blank=True, null=True) license = models.CharField(max_length=50) diff --git a/openverse-api/catalog/api/serializers/audio_serializers.py b/openverse-api/catalog/api/serializers/audio_serializers.py index 782c4bc80..df4d36837 100644 --- a/openverse-api/catalog/api/serializers/audio_serializers.py +++ b/openverse-api/catalog/api/serializers/audio_serializers.py @@ -1,3 +1,4 @@ +from django.urls import reverse from rest_framework import serializers from catalog.api.controllers.search_controller import get_sources @@ -95,6 +96,9 @@ class AudioSerializer(MediaSerializer): used to generate Swagger documentation. """ + thumbnail = serializers.SerializerMethodField( + help_text="A direct link to the miniature artwork." + ) audio_set = serializers.PrimaryKeyRelatedField( required=False, help_text='Reference to set of which this track is a part.', @@ -141,6 +145,12 @@ class AudioSerializer(MediaSerializer): help_text="A link to an endpoint that provides similar audio files." ) + def get_thumbnail(self, obj): + request = self.context['request'] + host = request.get_host() + path = reverse('audio-thumb', kwargs={'identifier': obj.identifier}) + return f'https://{host}{path}' + class AudioSearchResultsSerializer(MediaSearchResultsSerializer): """ The full audio search response. """ diff --git a/openverse-api/catalog/api/serializers/image_serializers.py b/openverse-api/catalog/api/serializers/image_serializers.py index b4a9380a6..a81ea8529 100644 --- a/openverse-api/catalog/api/serializers/image_serializers.py +++ b/openverse-api/catalog/api/serializers/image_serializers.py @@ -130,18 +130,6 @@ def get_thumbnail(self, obj): return f'https://{host}{path}' -class ProxiedImageSerializer(serializers.Serializer): - """ - We want to show 3rd party content securely and under our own native URLs, so - we route some images through our own proxy. We use this same endpoint to - generate thumbnails for content. - """ - full_size = serializers.BooleanField( - default=False, - help_text="If set, do not thumbnail the image." - ) - - class ImageSearchResultsSerializer(MediaSearchResultsSerializer): """ The full image search response. """ results = ImageSerializer( diff --git a/openverse-api/catalog/api/serializers/media_serializers.py b/openverse-api/catalog/api/serializers/media_serializers.py index d2bce4df0..442e50bd7 100644 --- a/openverse-api/catalog/api/serializers/media_serializers.py +++ b/openverse-api/catalog/api/serializers/media_serializers.py @@ -396,3 +396,15 @@ class AboutMediaSerializer(serializers.Serializer): source_url = serializers.CharField( help_text="The actual URL to the `source_name`." ) + + +class ProxiedImageSerializer(serializers.Serializer): + """ + We want to show 3rd party content securely and under our own native URLs, so + we route some images through our own proxy. We use this same endpoint to + generate thumbnails for content. + """ + full_size = serializers.BooleanField( + default=False, + help_text="If set, do not thumbnail the image." + ) diff --git a/openverse-api/catalog/api/views/audio_views.py b/openverse-api/catalog/api/views/audio_views.py index 4de71d599..62f5ea8c4 100644 --- a/openverse-api/catalog/api/views/audio_views.py +++ b/openverse-api/catalog/api/views/audio_views.py @@ -20,6 +20,7 @@ audio_stats_200_example, ) from catalog.api.models import Audio, AudioReport +from catalog.api.serializers.media_serializers import ProxiedImageSerializer from catalog.api.serializers.audio_serializers import ( AudioSearchQueryStringSerializer, AudioSearchResultsSerializer, @@ -40,6 +41,7 @@ RelatedMedia, MediaDetail, MediaStats, + ImageProxy, ) from catalog.custom_auto_schema import CustomAutoSchema @@ -237,3 +239,32 @@ class AudioStats(MediaStats): ]) def get(self, request, format=None): return self._get(request, 'audio') + + +class AudioArt(ImageProxy): + """ + Return the thumbnail of the artwork of the audio. This returns the thumbnail + of the audio, falling back to the thumbnail of the audio set. + """ + + queryset = Audio.objects.all() + + def get(self, request, identifier, format=None): + serialized = ProxiedImageSerializer(data=request.data) + serialized.is_valid() + try: + audio = Audio.objects.get(identifier=identifier) + image_url = audio.thumbnail + if not image_url: + image_url = audio.audio_set.url + except Audio.DoesNotExist: + return Response(status=404, data='Audio not found') + except AttributeError: + return Response(status=404, data='Audio set not found') + if not image_url: + return Response(status=404, data='Cover art URL not found') + + if serialized.data['full_size']: + return self._get(image_url, None) + else: + return self._get(image_url) diff --git a/openverse-api/catalog/api/views/image_views.py b/openverse-api/catalog/api/views/image_views.py index e017289cb..4b83bf961 100644 --- a/openverse-api/catalog/api/views/image_views.py +++ b/openverse-api/catalog/api/views/image_views.py @@ -5,7 +5,6 @@ import piexif import requests from PIL import Image as img -from django.conf import settings from django.urls import reverse from django.http.response import HttpResponse, FileResponse from drf_yasg import openapi @@ -14,8 +13,6 @@ from rest_framework.generics import GenericAPIView, CreateAPIView from rest_framework.response import Response from rest_framework.views import APIView -from urllib.error import HTTPError -from urllib.request import urlopen import catalog.api.controllers.search_controller as search_controller from catalog.api.examples import ( @@ -40,6 +37,7 @@ InputErrorSerializer, NotFoundErrorSerializer, ) +from catalog.api.serializers.media_serializers import ProxiedImageSerializer from catalog.api.serializers.image_serializers import ( ImageSearchQueryStringSerializer, ImageSearchResultsSerializer, @@ -49,10 +47,8 @@ OembedSerializer, OembedResponseSerializer, AboutImageSerializer, - ProxiedImageSerializer, ) from catalog.api.utils import ccrel -from catalog.api.utils.throttle import OneThousandPerMinute from catalog.api.utils.exceptions import input_error_response from catalog.api.utils.watermark import watermark from catalog.api.views.media_views import ( @@ -65,6 +61,7 @@ RelatedMedia, MediaDetail, MediaStats, + ImageProxy, ) from catalog.custom_auto_schema import CustomAutoSchema @@ -436,42 +433,23 @@ def get(self, request, format=None): return self._get(request, 'image') -class ProxiedImage(APIView): +class ProxiedImage(ImageProxy): """ Return the thumb of an image. """ - lookup_field = 'identifier' queryset = Image.objects.all() - throttle_classes = [OneThousandPerMinute] - swagger_schema = None def get(self, request, identifier, format=None): serialized = ProxiedImageSerializer(data=request.data) serialized.is_valid() try: image = Image.objects.get(identifier=identifier) + image_url = image.url except Image.DoesNotExist: return Response(status=404, data='Not Found') if serialized.data['full_size']: - proxy_upstream = f'{settings.THUMBNAIL_PROXY_URL}/{image.url}' + return self._get(image_url, None) else: - proxy_upstream = f'{settings.THUMBNAIL_PROXY_URL}/' \ - f'{settings.THUMBNAIL_WIDTH_PX}' \ - f',fit/{image.url}' - try: - upstream_response = urlopen(proxy_upstream) - status = upstream_response.status - content_type = upstream_response.headers.get('Content-Type') - except HTTPError: - log.info('Failed to render thumbnail: ', exc_info=True) - return HttpResponse(status=500) - - response = HttpResponse( - upstream_response.read(), - status=status, - content_type=content_type - ) - - return response + return self._get(image_url) diff --git a/openverse-api/catalog/api/views/media_views.py b/openverse-api/catalog/api/views/media_views.py index 12f2ac722..831095c54 100644 --- a/openverse-api/catalog/api/views/media_views.py +++ b/openverse-api/catalog/api/views/media_views.py @@ -1,16 +1,25 @@ +import logging + +from django.conf import settings +from django.http.response import HttpResponse from rest_framework.authentication import BasicAuthentication from rest_framework.generics import GenericAPIView from rest_framework.mixins import RetrieveModelMixin from rest_framework.permissions import IsAuthenticatedOrReadOnly from rest_framework.response import Response from rest_framework.views import APIView +from urllib.error import HTTPError +from urllib.request import urlopen from catalog.api.controllers import search_controller from catalog.api.controllers.search_controller import get_sources from catalog.api.models import ContentProvider, SourceLogo from catalog.api.utils.exceptions import input_error_response +from catalog.api.utils.throttle import OneThousandPerMinute from catalog.custom_auto_schema import CustomAutoSchema +log = logging.getLogger(__name__) + FOREIGN_LANDING_URL = 'foreign_landing_url' CREATOR_URL = 'creator_url' RESULTS = 'results' @@ -188,3 +197,33 @@ def _get(self, request, index_name): } ) return Response(status=200, data=response) + + +class ImageProxy(APIView): + swagger_schema = None + + lookup_field = 'identifier' + throttle_classes = [OneThousandPerMinute] + + def _get(self, media_url, width=settings.THUMBNAIL_WIDTH_PX): + if width is None: # full size + proxy_upstream = f'{settings.THUMBNAIL_PROXY_URL}/{media_url}' + else: + proxy_upstream = f'{settings.THUMBNAIL_PROXY_URL}/' \ + f'{settings.THUMBNAIL_WIDTH_PX},fit/' \ + f'{media_url}' + try: + upstream_response = urlopen(proxy_upstream) + status = upstream_response.status + content_type = upstream_response.headers.get('Content-Type') + except HTTPError: + log.info('Failed to render thumbnail: ', exc_info=True) + return HttpResponse(status=500) + + response = HttpResponse( + upstream_response.read(), + status=status, + content_type=content_type + ) + + return response diff --git a/openverse-api/catalog/urls/audio.py b/openverse-api/catalog/urls/audio.py index 1f2893e38..591318014 100644 --- a/openverse-api/catalog/urls/audio.py +++ b/openverse-api/catalog/urls/audio.py @@ -5,6 +5,7 @@ AudioDetail, RelatedAudio, AudioStats, + AudioArt, ) urlpatterns = [ @@ -18,6 +19,11 @@ AudioDetail.as_view(), name='audio-detail' ), + path( + '/thumb', + AudioArt.as_view(), + name='audio-thumb' + ), path( '/recommendations', RelatedAudio.as_view(), diff --git a/openverse-api/test/audio_integration_test.py b/openverse-api/test/audio_integration_test.py index 6a5d1ae84..aa378bfc8 100644 --- a/openverse-api/test/audio_integration_test.py +++ b/openverse-api/test/audio_integration_test.py @@ -16,6 +16,7 @@ search_consistency, detail, stats, + thumb, ) @@ -50,3 +51,8 @@ def test_audio_detail(audio_fixture): def test_audio_stats(): stats('audio', 'audio_count') + + +@pytest.mark.skip(reason='No images have audio set image yet') +def test_audio_thumb(audio_fixture): + thumb(audio_fixture) diff --git a/openverse-api/test/image_integration_test.py b/openverse-api/test/image_integration_test.py index 6a3b4dcdc..5d2098269 100644 --- a/openverse-api/test/image_integration_test.py +++ b/openverse-api/test/image_integration_test.py @@ -17,6 +17,7 @@ search_consistency, detail, stats, + thumb, ) @@ -53,6 +54,10 @@ def test_image_stats(): stats('images', 'image_count') +def test_image_thumb(image_fixture): + thumb(image_fixture) + + def test_oembed_endpoint_for_json(): params = { 'url': 'https://any.domain/any/path/29cb352c-60c1-41d8-bfa1-7d6f7d955f63', diff --git a/openverse-api/test/media_integration.py b/openverse-api/test/media_integration.py index f444fa4a8..fe7a2f664 100644 --- a/openverse-api/test/media_integration.py +++ b/openverse-api/test/media_integration.py @@ -72,3 +72,10 @@ def stats(media_type, count_key): provider_count += 1 assert num_media > 0 assert provider_count > 0 + + +def thumb(fixture): + thumbnail_url = fixture['results'][0]['thumbnail'].replace('https:', 'http:') + thumbnail_response = requests.get(thumbnail_url) + assert thumbnail_response.status_code == 200 + assert thumbnail_response.headers["Content-Type"].startswith("image/") diff --git a/openverse-api/test/v1_integration_test.py b/openverse-api/test/v1_integration_test.py index 59e8479c9..b6af528f3 100644 --- a/openverse-api/test/v1_integration_test.py +++ b/openverse-api/test/v1_integration_test.py @@ -27,14 +27,6 @@ def image_fixture(): return parsed -@pytest.fixture -def test_image_thumb(image_fixture): - thumbnail_url = image_fixture['results'][0]['thumbnail'] - thumbnail_response = requests.get(thumbnail_url) - assert thumbnail_response.status_code == 200 - assert thumbnail_response.headers["Content-Type"].startswith("image/") - - def test_link_shortener_create(): payload = {'full_url': 'abcd'} response = requests.post(f'{API_URL}/v1/link/', json=payload, verify=False)