Merge branch 'master' into static-media
[mygpo.git] / mygpo / web / logo.py
blob07cc8c3ad9c555ffff5d7386b254a05792806574
1 import os.path
2 import io
3 import requests
4 import hashlib
5 import socket
6 import struct
8 from PIL import Image, ImageDraw
10 from django.urls import reverse
11 from django.conf import settings
12 from django.http import Http404, HttpResponseRedirect
13 from django.views import View
14 from django.utils.decorators import method_decorator
15 from django.views.decorators.http import last_modified
16 from django.contrib.staticfiles.storage import staticfiles_storage
17 from django.core.files.storage import FileSystemStorage
19 from mygpo.utils import file_hash
21 import logging
22 logger = logging.getLogger(__name__)
24 # Use Django's File Storage API to access podcast logos. This could be swapped
25 # out for another storage implementation (eg for storing to Amazon S3)
26 # https://docs.djangoproject.com/en/1.11/ref/files/storage/
27 LOGO_STORAGE = FileSystemStorage(
28 location=settings.MEDIA_ROOT,
32 def _last_modified(request, size, prefix, filename):
34 target = os.path.join('logo', str(size), prefix, filename)
36 try:
37 return LOGO_STORAGE.get_modified_time(target)
39 except (FileNotFoundError, NotImplementedError):
40 return None
43 class CoverArt(View):
45 def __init__(self):
46 self.storage = LOGO_STORAGE
48 @method_decorator(last_modified(_last_modified))
49 def get(self, request, size, prefix, filename):
51 size = int(size)
53 prefix = get_prefix(filename)
54 target = self.get_thumbnail_path(size, prefix, filename)
55 original = self.get_original_path(prefix, filename)
57 if self.storage.exists(target):
58 return self.send_file(target)
60 if not self.storage.exists(original):
61 logger.warning('Original cover {} not found'.format(original))
62 raise Http404('Cover Art not available' + original)
64 target_dir = self.get_dir(filename)
65 try:
66 fp = self.storage.open(original, 'rb')
67 im = Image.open(fp)
68 if im.mode not in ('RGB', 'RGBA'):
69 im = im.convert('RGBA')
70 except IOError as ioe:
71 logger.warning('Cover file {} cannot be opened: {}'.format(
72 original, ioe))
73 raise Http404('Cannot open cover file') from ioe
75 try:
76 im.thumbnail((size, size), Image.ANTIALIAS)
77 resized = im
78 except (struct.error, IOError, IndexError) as ex:
79 # raised when trying to read an interlaced PNG;
80 logger.warning('Could not create thumbnail: %s', str(ex))
82 # we use the original instead
83 return self.send_file(original)
85 sio = io.BytesIO()
87 try:
88 resized.save(sio, 'JPEG', optimize=True, progression=True,
89 quality=80)
90 except IOError as ex:
91 return self.send_file(original)
92 finally:
93 fp.close()
95 self.storage.save(target, sio)
97 return self.send_file(target)
99 @staticmethod
100 def get_thumbnail_path(size, prefix, filename):
101 return os.path.join('logo', str(size), prefix, filename)
103 @staticmethod
104 def get_dir(filename):
105 return os.path.dirname(filename)
107 @staticmethod
108 def remove_existing_thumbnails(prefix, filename):
109 dirs, _files = LOGO_STORAGE.listdir('logo') # TODO: cache list of sizes
110 for size in dirs:
111 if size == 'original':
112 continue
114 path = os.path.join('logo', size, prefix, filename)
115 logger.info('Removing {}'.format(path))
116 LOGO_STORAGE.delete(path)
118 @staticmethod
119 def get_original_path(prefix, filename):
120 return os.path.join('logo', 'original', prefix, filename)
122 def send_file(self, filename):
123 return HttpResponseRedirect(LOGO_STORAGE.url(filename))
125 @classmethod
126 def save_podcast_logo(cls, cover_art_url):
127 if not cover_art_url:
128 return
130 try:
131 image_sha1 = hashlib.sha1(cover_art_url.encode('utf-8')).hexdigest()
132 prefix = get_prefix(image_sha1)
134 filename = cls.get_original_path(prefix, image_sha1)
135 dirname = cls.get_dir(filename)
137 # get hash of existing file
138 if LOGO_STORAGE.exists(filename):
139 with LOGO_STORAGE.open(filename, 'rb') as f:
140 old_hash = file_hash(f).digest()
141 else:
142 old_hash = ''
144 logger.info('Logo {}, saving to {}'.format(cover_art_url, filename))
146 # save new cover art
147 LOGO_STORAGE.delete(filename)
148 source = io.BytesIO(requests.get(cover_art_url).content)
149 LOGO_STORAGE.save(filename, source)
151 # get hash of new file
152 with LOGO_STORAGE.open(filename, 'rb') as f:
153 new_hash = file_hash(f).digest()
155 # remove thumbnails if cover changed
156 if old_hash != new_hash:
157 logger.info('Removing thumbnails')
158 thumbnails = cls.remove_existing_thumbnails(prefix, filename)
160 return cover_art_url
162 except (ValueError, requests.exceptions.RequestException,
163 socket.error, IOError) as e:
164 logger.warning('Exception while updating podcast logo: %s', str(e))
167 def get_prefix(filename):
168 return filename[:3]
171 def get_logo_url(podcast, size):
172 """ Return the logo URL for the podcast
174 The logo either comes from the media storage (see CoverArt) or from the
175 default logos in the static storage.
178 if podcast.logo_url:
179 filename = hashlib.sha1(podcast.logo_url.encode('utf-8')).hexdigest()
180 return reverse('logo', args=[size, get_prefix(filename), filename])
182 else:
183 filename = 'podcast-%d.png' % (hash(podcast.title) % 5, )
184 return staticfiles_storage.url('logo/{0}'.format(filename))