Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to convert cover art file format. #4133

Merged
merged 3 commits into from
Nov 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 106 additions & 1 deletion beets/util/artresizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import subprocess
import os
import os.path
import re
from tempfile import NamedTemporaryFile
from urllib.parse import urlencode
Expand Down Expand Up @@ -234,6 +235,72 @@ def im_deinterlace(path_in, path_out=None):
}


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
Expand Down Expand Up @@ -318,12 +385,50 @@ def get_size(self, path_in):
"""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"""

Expand Down
27 changes: 26 additions & 1 deletion beetsplug/fetchart.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class Candidate:
CANDIDATE_DOWNSCALE = 2
CANDIDATE_DOWNSIZE = 3
CANDIDATE_DEINTERLACE = 4
CANDIDATE_REFORMAT = 5

MATCH_EXACT = 0
MATCH_FALLBACK = 1
Expand All @@ -74,12 +75,14 @@ def _validate(self, plugin):
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
Expand Down Expand Up @@ -142,12 +145,23 @@ def _validate(self, plugin):
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

Expand All @@ -169,6 +183,12 @@ def resize(self, plugin):
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):
Expand Down Expand Up @@ -923,6 +943,7 @@ def __init__(self):
'store_source': False,
'high_resolution': False,
'deinterlace': False,
'cover_format': None,
})
self.config['google_key'].redact = True
self.config['fanarttv_key'].redact = True
Expand Down Expand Up @@ -959,6 +980,10 @@ def __init__(self):
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]
Expand Down
2 changes: 2 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ Other new things:
* :doc:`/plugins/fetchart`: A new option to store cover art as non-progressive
image. Useful for DAPs that support progressive images. Set ``deinterlace:
yes`` in your configuration to enable.
* :doc:`/plugins/fetchart`: A new option to change cover art format. Useful for
DAPs that do not support some image formats.

For plugin developers:

Expand Down
10 changes: 10 additions & 0 deletions docs/plugins/fetchart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ file. The available options are:
instructed to store cover art as non-progressive JPEG. You might need this if
you use DAPs that don't support progressive images.
Default: ``no``.
- **cover_format**: If enabled, forced the cover image into the specified
format. Most often, this will be either ``JPEG`` or ``PNG`` [#imgformats]_.
Also respects ``deinterlace``.
Default: None (leave unchanged).

Note: ``maxwidth`` and ``enforce_ratio`` options require either `ImageMagick`_
or `Pillow`_.
Expand All @@ -105,6 +109,12 @@ or `Pillow`_.
.. _beets custom search engine: https://cse.google.com.au:443/cse/publicurl?cx=001442825323518660753:hrh5ch1gjzm
.. _Pillow: https://github.com/python-pillow/Pillow
.. _ImageMagick: https://www.imagemagick.org/
.. [#imgformats] Other image formats are available, though the full list
depends on your system and what backend you are using. If you're using the
ImageMagick backend, you can use ``magick identify -list format`` to get a
full list of all supported formats, and you can use the Python function
PIL.features.pilinfo() to print a list of all supported formats in Pillow
(``python3 -c 'import PIL.features as f; f.pilinfo()'``).

Here's an example that makes plugin select only images that contain ``front`` or
``back`` keywords in their filenames and prioritizes the iTunes source over
Expand Down