"""
Find, download and re-upload poster for movie, series or season
"""
import collections
import hashlib
import os
import re
import urllib.parse
import async_lru
from .. import errors, uis, utils
from . import JobBase
import logging # isort:skip
_log = logging.getLogger(__name__)
[docs]
class PosterJob(JobBase):
"""
Get poster and optionally resize and reupload it
This job adds the following signals to :attr:`~.JobBase.signal`:
``obtaining``
Emitted when getting a poster is attempted. Registered callbacks get no arguments.
``obtained``
Emitted when a poster was successfully obtained. Registered callbacks get the poster
file path or URL as a positional argument.
``downloading``
Emitted when downloading a poster is attempted. Registered callbacks get the poster URL
as a positional argument.
``downloaded``
Emitted when a poster was successfully downloaded. Registered callbacks get the poster
URL as a positional argument.
``resizing``
Emitted when resizing a poster is attempted. Registered callbacks get the original
poster file path as a positional argument.
``resized``
Emitted when a poster was successfully resized. Registered callbacks get the resized
poster file path as a positional argument.
``uploading``
Emitted when uploading a poster to an image hosting service is attempted. Registered
callbacks get the relevant :class:`~.ImagehostBase` subclass as a positional argument.
``uploaded``
Emitted when a poster was successfully uploaded to an image hosting service. Registered
callbacks get the URL of the uploaded poster as a positional argument.
"""
name = 'poster'
label = 'Poster'
# Caching is done by utilities.
cache_id = None
[docs]
def initialize(self, *, getter, width=None, height=None, write_to=None, imagehosts=()):
"""
Set internal state
:param getter: Coroutine function that returns a poster file or poster URL
(e.g. :meth:`.WebDbApiBase.poster_url`). May raise :class:`~.RequestError`, which is
passed to :meth:`~.JobBase.error`.
:param width: Resize poster to this many pixels wide (aspect ratio is maintained)
:param height: Resize poster to this many pixels high (aspect ratio is maintained)
:param imagehosts: Sequence of :class:`~.ImagehostBase` subclass instances
Upload poster to the first, try the next one if it fails and so on.
:class:`~.RequestError` from uploading is passed to :meth:`~.JobBase.warn`. If all
uploads fail, :meth:`error` is called. Any failed image host is considered broken and
will not be used for subsequent images.
:param write_to: Write poster to this file path (may be `None` or empty string)
"""
self._getter = getter
self._width = width
self._height = height
self._write_to = write_to
self._imagehosts = imagehosts
self.signal.add('obtaining')
self.signal.add('obtained')
self.signal.add('downloading')
self.signal.add('downloaded')
self.signal.add('resizing')
self.signal.add('resized')
self.signal.add('uploading')
self.signal.add('uploaded')
_url_regex = re.compile(r'^https?://.+', flags=re.IGNORECASE)
class _ProcessingError(errors.UpsiesError):
pass
[docs]
async def run(self):
try:
params = await self._obtain()
poster = params['poster']
width = params.get('width', self._width)
height = params.get('height', self._height)
write_to = params.get('write_to', self._write_to)
imagehosts = params.get('imagehosts', self._imagehosts)
poster = await self._resize(poster, width, height)
await self._write(poster, write_to)
await self._upload(poster, imagehosts)
if not write_to and not imagehosts:
if width or height:
await self._write(poster, self._get_poster_filename(poster))
else:
self.add_output(poster)
except self._ProcessingError as e:
self.error(e)
async def _obtain(self):
_log.debug('Obtaining poster: %r', self._getter)
self.signal.emit('obtaining')
try:
params_or_poster = await self._getter()
except errors.RequestError as e:
self.warn(f'Failed to get poster: {e}')
params_or_poster = None
_log.debug('Obtained poster: %r', params_or_poster)
if not params_or_poster:
params = await self._obtain_via_prompt()
elif isinstance(params_or_poster, collections.abc.Mapping):
params = params_or_poster
else:
params = {'poster': params_or_poster}
self.signal.emit('obtained', params['poster'])
return params
async def _obtain_via_prompt(self):
self.info = 'Please enter a poster file or URL.'
try:
poster = ''
while True:
poster = os.path.expanduser(await self.add_prompt(
uis.prompts.TextPrompt(text=poster)
))
if not poster:
self.warn('Poster file or URL is required.')
elif self._url_regex.search(poster):
# Download poster just to get an error if it fails. Later
# downloads should grab it from cache without pestering the
# server.
try:
await utils.http.get(poster, cache=True)
except errors.RequestError as e:
self.warn(f'Failed to download poster: {e}')
else:
return {'poster': poster}
elif not os.path.exists(poster):
self.warn(f'Poster file does not exist: {poster}')
elif not os.path.isfile(poster):
self.warn(f'Poster is not a file: {poster}')
else:
return {'poster': poster}
finally:
self.clear_warnings()
async def _resize(self, poster, width, height):
if width or height:
_log.debug('Resizing poster to %s x %s: %r', width, height, poster)
# Download the poster so we can resize it.
filepath = await self._get_poster_filepath(poster)
filename_resized = '.'.join((
utils.fs.basename(utils.fs.strip_extension(filepath)),
f'w{width}h{height}',
utils.fs.file_extension(filepath),
))
self.signal.emit('resizing', filepath)
try:
filepath_resized = utils.image.resize(
filepath,
target_directory=self.cache_directory,
target_filename=filename_resized,
width=width,
height=height,
)
except errors.ImageResizeError as e:
raise self._ProcessingError(f'Failed to resize poster: {e}') from e
else:
self.signal.emit('resized', filepath_resized)
return filepath_resized
else:
# Return original poster file or URL.
return poster
async def _write(self, poster, filepath):
if filepath:
# Write poster file or URL to user-provided path.
_log.debug('Writing poster: %r', (poster, filepath))
data = await self._read_file_or_url(poster)
filepath = await self._write_file(data, filepath)
self.add_output(filepath)
async def _upload(self, poster, imagehosts):
if imagehosts:
# If poster is a URL, we must download it first.
filepath = await self._get_poster_filepath(poster)
# Upload `filepath` to any `imagehost` and return the URL of the first successful
# upload.
for imagehost in imagehosts:
_log.debug('Uploading poster: %r', (filepath, imagehost.name))
self.signal.emit('uploading', imagehost)
try:
url = await imagehost.upload(filepath, thumb_width=0)
except errors.RequestError as e:
self.warn(f'Failed to upload poster: {e}')
else:
self.signal.emit('uploaded', url)
self.add_output(url)
return
# If all upload() calls failed, add an error to all the warnings.
raise self._ProcessingError('All uploads failed')
@async_lru.alru_cache
async def _get_poster_filepath(self, poster):
if self._url_regex.search(poster):
# Download poster URL to temporary file and return its path.
data = await self._read_file_or_url(poster)
filepath = os.path.join(
self.cache_directory,
self._get_poster_filename(poster),
)
return await self._write_file(data, filepath)
else:
# Return original poster file path.
return poster
def _get_poster_filename(self, poster):
if self._url_regex.search(poster):
# Turn poster URL into unique file name.
url = urllib.parse.urlparse(poster)
unique_id = hashlib.md5(
'.'.join((url.path, url.query))
.encode('utf8')
).hexdigest()
filename = 'poster:' + '.'.join((
url.hostname,
unique_id,
))
extension = utils.fs.file_extension(url.path)
if extension:
filename += f'.{extension}'
return filename
else:
# Get poster file name from poster file path.
return utils.fs.basename(poster)
async def _read_file_or_url(self, poster):
if self._url_regex.search(poster):
# Return downloaded data from URL.
_log.debug('Downloading poster: %r', poster)
try:
self.signal.emit('downloading', poster)
response = await utils.http.get(poster, cache=True)
except errors.RequestError as e:
raise self._ProcessingError(f'Failed to download poster: {e}') from e
else:
self.signal.emit('downloaded', poster)
return response.bytes
else:
# Return poster file content.
_log.debug('Reading poster: %r', poster)
try:
with open(poster, 'rb') as f:
return f.read()
except OSError as e:
msg = e.strerror or str(e)
raise self._ProcessingError(f'Failed to read poster: {msg}') from e
async def _write_file(self, data, filepath):
# Write `data` to sanitized `filepath` and return sanitized `filepath`.
filepath = utils.fs.sanitize_path(filepath)
try:
with open(filepath, 'wb') as f:
f.write(data)
except OSError as e:
msg = e.strerror or str(e)
raise self._ProcessingError(f'Failed to write {filepath}: {msg}') from e
else:
return filepath