fetchart: add option to force cover format

This commit is contained in:
Arsen Arsenović 2021-11-02 12:51:27 +01:00
parent 8fb1c03ca5
commit 0b578a3384
No known key found for this signature in database
GPG key ID: 4745351A0CC0C1BC
2 changed files with 132 additions and 2 deletions

View file

@ -18,6 +18,7 @@ public resizing proxy if neither is available.
import subprocess
import os
import os.path
import re
from tempfile import NamedTemporaryFile
from urllib.parse import urlencode
@ -234,6 +235,72 @@ DEINTERLACE_FUNCS = {
}
def im_get_format(filepath):
cmd = ArtResizer.shared.im_identify_cmd + [
'-format', '%[magick]',
util.syspath(filepath)
]
try:
return util.command_output(cmd).stdout
except subprocess.CalledProcessError:
return None
def pil_get_format(filepath):
from PIL import Image, UnidentifiedImageError
try:
with Image.open(util.syspath(filepath)) as im:
return im.format
except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError):
log.exception("failed to detect image format for {}", filepath)
return None
BACKEND_GET_FORMAT = {
PIL: pil_get_format,
IMAGEMAGICK: im_get_format,
}
def im_convert_format(source, target, deinterlaced):
cmd = ArtResizer.shared.im_convert_cmd + [
util.syspath(source),
*(["-interlace", "none"] if deinterlaced else []),
util.syspath(target),
]
try:
subprocess.check_call(
cmd,
stderr=subprocess.DEVNULL,
stdout=subprocess.DEVNULL
)
return target
except subprocess.CalledProcessError:
return source
def pil_convert_format(source, target, deinterlaced):
from PIL import Image, UnidentifiedImageError
try:
with Image.open(util.syspath(source)) as im:
im.save(util.py3_path(target), progressive=not deinterlaced)
return target
except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError,
OSError):
log.exception("failed to convert image {} -> {}", source, target)
return source
BACKEND_CONVERT_IMAGE_FORMAT = {
PIL: pil_convert_format,
IMAGEMAGICK: im_convert_format,
}
class Shareable(type):
"""A pseudo-singleton metaclass that allows both shared and
non-shared instances. The ``MyClass.shared`` property holds a
@ -318,12 +385,50 @@ class ArtResizer(metaclass=Shareable):
"""Return the size of an image file as an int couple (width, height)
in pixels.
Only available locally
Only available locally.
"""
if self.local:
func = BACKEND_GET_SIZE[self.method[0]]
return func(path_in)
def get_format(self, path_in):
"""Returns the format of the image as a string.
Only available locally.
"""
if self.local:
func = BACKEND_GET_FORMAT[self.method[0]]
return func(path_in)
def reformat(self, path_in, new_format, deinterlaced=True):
"""Converts image to desired format, updating its extension, but
keeping the same filename.
Only available locally.
"""
if not self.local:
return path_in
new_format = new_format.lower()
# A nonexhaustive map of image "types" to extensions overrides
new_format = {
'jpeg': 'jpg',
}.get(new_format, new_format)
fname, ext = os.path.splitext(path_in)
path_new = fname + b'.' + new_format.encode('utf8')
func = BACKEND_CONVERT_IMAGE_FORMAT[self.method[0]]
# allows the exception to propagate, while still making sure a changed
# file path was removed
result_path = path_in
try:
result_path = func(path_in, path_new, deinterlaced)
finally:
if result_path != path_in:
os.unlink(path_in)
return result_path
def _can_compare(self):
"""A boolean indicating whether image comparison is available"""

View file

@ -50,6 +50,7 @@ class Candidate:
CANDIDATE_DOWNSCALE = 2
CANDIDATE_DOWNSIZE = 3
CANDIDATE_DEINTERLACE = 4
CANDIDATE_REFORMAT = 5
MATCH_EXACT = 0
MATCH_FALLBACK = 1
@ -74,12 +75,14 @@ class Candidate:
Return `CANDIDATE_DOWNSIZE` if the file must be resized, and possibly
also rescaled.
Return `CANDIDATE_DEINTERLACE` if the file must be deinterlaced.
Return `CANDIDATE_REFORMAT` if the file has to be converted.
"""
if not self.path:
return self.CANDIDATE_BAD
if (not (plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth
or plugin.max_filesize or plugin.deinterlace)):
or plugin.max_filesize or plugin.deinterlace
or plugin.cover_format)):
return self.CANDIDATE_EXACT
# get_size returns None if no local imaging backend is available
@ -142,12 +145,23 @@ class Candidate:
filesize, plugin.max_filesize)
downsize = True
# Check image format
reformat = False
if plugin.cover_format:
fmt = ArtResizer.shared.get_format(self.path)
reformat = fmt != plugin.cover_format
if reformat:
self._log.debug('image needs reformatting: {} -> {}',
fmt, plugin.cover_format)
if downscale:
return self.CANDIDATE_DOWNSCALE
elif downsize:
return self.CANDIDATE_DOWNSIZE
elif plugin.deinterlace:
return self.CANDIDATE_DEINTERLACE
elif reformat:
return self.CANDIDATE_REFORMAT
else:
return self.CANDIDATE_EXACT
@ -169,6 +183,12 @@ class Candidate:
max_filesize=plugin.max_filesize)
elif self.check == self.CANDIDATE_DEINTERLACE:
self.path = ArtResizer.shared.deinterlace(self.path)
elif self.check == self.CANDIDATE_REFORMAT:
self.path = ArtResizer.shared.reformat(
self.path,
plugin.cover_format,
deinterlaced=plugin.deinterlace,
)
def _logged_get(log, *args, **kwargs):
@ -923,6 +943,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
'store_source': False,
'high_resolution': False,
'deinterlace': False,
'cover_format': None,
})
self.config['google_key'].redact = True
self.config['fanarttv_key'].redact = True
@ -959,6 +980,10 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
self.src_removed = (config['import']['delete'].get(bool) or
config['import']['move'].get(bool))
self.cover_format = self.config['cover_format'].get(
confuse.Optional(str)
)
if self.config['auto']:
# Enable two import hooks when fetching is enabled.
self.import_stages = [self.fetch_art]