artresizer: backend classes part 1: stub classes, version checks

This commit is contained in:
wisp3rwind 2022-03-12 11:23:54 +01:00
parent bac93e1095
commit 8a969e3041
6 changed files with 222 additions and 151 deletions

View file

@ -61,7 +61,98 @@ def temp_file_for(path):
return bytestring_path(f.name) return bytestring_path(f.name)
def pil_resize(artresizer, maxwidth, path_in, path_out=None, quality=0, class LocalBackendNotAvailableError(Exception):
pass
_NOT_AVAILABLE = object()
class LocalBackend:
@classmethod
def available(cls):
try:
cls.version()
return True
except LocalBackendNotAvailableError:
return False
class IMBackend(LocalBackend):
NAME="ImageMagick"
ID=IMAGEMAGICK
_version = None
_legacy = None
@classmethod
def version(cls):
"""Obtain and cache ImageMagick version.
Raises `LocalBackendNotAvailableError` if not available.
"""
if cls._version is None:
for cmd_name, legacy in (('magick', False), ('convert', True)):
try:
out = util.command_output([cmd_name, "--version"]).stdout
except (subprocess.CalledProcessError, OSError) as exc:
log.debug('ImageMagick version check failed: {}', exc)
cls._version = _NOT_AVAILABLE
else:
if b'imagemagick' in out.lower():
pattern = br".+ (\d+)\.(\d+)\.(\d+).*"
match = re.search(pattern, out)
if match:
cls._version = (int(match.group(1)),
int(match.group(2)),
int(match.group(3)))
cls._legacy = legacy
if cls._version is _NOT_AVAILABLE:
raise LocalBackendNotAvailableError()
else:
return cls._version
def __init__(self):
"""Initialize a wrapper around ImageMagick for local image operations.
Stores the ImageMagick version and legacy flag. If ImageMagick is not
available, raise an Exception.
"""
self.version()
# Use ImageMagick's magick binary when it's available.
# If it's not, fall back to the older, separate convert
# and identify commands.
if self._legacy:
self.convert_cmd = ['convert']
self.identify_cmd = ['identify']
self.compare_cmd = ['compare']
else:
self.convert_cmd = ['magick']
self.identify_cmd = ['magick', 'identify']
self.compare_cmd = ['magick', 'compare']
class PILBackend(LocalBackend):
NAME="PIL"
ID=PIL
@classmethod
def version(cls):
try:
__import__('PIL', fromlist=['Image'])
except ImportError:
raise LocalBackendNotAvailableError()
def __init__(self):
"""Initialize a wrapper around PIL for local image operations.
If PIL is not available, raise an Exception.
"""
self.version()
def pil_resize(backend, maxwidth, path_in, path_out=None, quality=0,
max_filesize=0): max_filesize=0):
"""Resize using Python Imaging Library (PIL). Return the output path """Resize using Python Imaging Library (PIL). Return the output path
of resized image. of resized image.
@ -120,7 +211,7 @@ def pil_resize(artresizer, maxwidth, path_in, path_out=None, quality=0,
return path_in return path_in
def im_resize(artresizer, maxwidth, path_in, path_out=None, quality=0, def im_resize(backend, maxwidth, path_in, path_out=None, quality=0,
max_filesize=0): max_filesize=0):
"""Resize using ImageMagick. """Resize using ImageMagick.
@ -136,7 +227,7 @@ def im_resize(artresizer, maxwidth, path_in, path_out=None, quality=0,
# with regards to the height. # with regards to the height.
# ImageMagick already seems to default to no interlace, but we include it # ImageMagick already seems to default to no interlace, but we include it
# here for the sake of explicitness. # here for the sake of explicitness.
cmd = artresizer.im_convert_cmd + [ cmd = backend.convert_cmd + [
syspath(path_in, prefix=False), syspath(path_in, prefix=False),
'-resize', f'{maxwidth}x>', '-resize', f'{maxwidth}x>',
'-interlace', 'none', '-interlace', 'none',
@ -168,7 +259,7 @@ BACKEND_FUNCS = {
} }
def pil_getsize(artresizer, path_in): def pil_getsize(backend, path_in):
from PIL import Image from PIL import Image
try: try:
@ -180,8 +271,8 @@ def pil_getsize(artresizer, path_in):
return None return None
def im_getsize(artresizer, path_in): def im_getsize(backend, path_in):
cmd = artresizer.im_identify_cmd + \ cmd = backend.identify_cmd + \
['-format', '%w %h', syspath(path_in, prefix=False)] ['-format', '%w %h', syspath(path_in, prefix=False)]
try: try:
@ -207,7 +298,7 @@ BACKEND_GET_SIZE = {
} }
def pil_deinterlace(artresizer, path_in, path_out=None): def pil_deinterlace(backend, path_in, path_out=None):
path_out = path_out or temp_file_for(path_in) path_out = path_out or temp_file_for(path_in)
from PIL import Image from PIL import Image
@ -219,10 +310,10 @@ def pil_deinterlace(artresizer, path_in, path_out=None):
return path_in return path_in
def im_deinterlace(artresizer, path_in, path_out=None): def im_deinterlace(backend, path_in, path_out=None):
path_out = path_out or temp_file_for(path_in) path_out = path_out or temp_file_for(path_in)
cmd = artresizer.im_convert_cmd + [ cmd = backend.convert_cmd + [
syspath(path_in, prefix=False), syspath(path_in, prefix=False),
'-interlace', 'none', '-interlace', 'none',
syspath(path_out, prefix=False), syspath(path_out, prefix=False),
@ -241,8 +332,8 @@ DEINTERLACE_FUNCS = {
} }
def im_get_format(artresizer, filepath): def im_get_format(backend, filepath):
cmd = artresizer.im_identify_cmd + [ cmd = backend.identify_cmd + [
'-format', '%[magick]', '-format', '%[magick]',
syspath(filepath) syspath(filepath)
] ]
@ -253,7 +344,7 @@ def im_get_format(artresizer, filepath):
return None return None
def pil_get_format(artresizer, filepath): def pil_get_format(backend, filepath):
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
try: try:
@ -270,8 +361,8 @@ BACKEND_GET_FORMAT = {
} }
def im_convert_format(artresizer, source, target, deinterlaced): def im_convert_format(backend, source, target, deinterlaced):
cmd = artresizer.im_convert_cmd + [ cmd = backend.convert_cmd + [
syspath(source), syspath(source),
*(["-interlace", "none"] if deinterlaced else []), *(["-interlace", "none"] if deinterlaced else []),
syspath(target), syspath(target),
@ -288,7 +379,7 @@ def im_convert_format(artresizer, source, target, deinterlaced):
return source return source
def pil_convert_format(artresizer, source, target, deinterlaced): def pil_convert_format(backend, source, target, deinterlaced):
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
try: try:
@ -307,7 +398,7 @@ BACKEND_CONVERT_IMAGE_FORMAT = {
} }
def im_compare(artresizer, im1, im2, compare_threshold): def im_compare(backend, im1, im2, compare_threshold):
is_windows = platform.system() == "Windows" is_windows = platform.system() == "Windows"
# Converting images to grayscale tends to minimize the weight # Converting images to grayscale tends to minimize the weight
@ -315,11 +406,11 @@ def im_compare(artresizer, im1, im2, compare_threshold):
# to grayscale and then pipe them into the `compare` command. # to grayscale and then pipe them into the `compare` command.
# On Windows, ImageMagick doesn't support the magic \\?\ prefix # On Windows, ImageMagick doesn't support the magic \\?\ prefix
# on paths, so we pass `prefix=False` to `syspath`. # on paths, so we pass `prefix=False` to `syspath`.
convert_cmd = artresizer.im_convert_cmd + [ convert_cmd = backend.convert_cmd + [
syspath(im2, prefix=False), syspath(im1, prefix=False), syspath(im2, prefix=False), syspath(im1, prefix=False),
'-colorspace', 'gray', 'MIFF:-' '-colorspace', 'gray', 'MIFF:-'
] ]
compare_cmd = artresizer.im_compare_cmd + [ compare_cmd = backend.compare_cmd + [
'-metric', 'PHASH', '-', 'null:', '-metric', 'PHASH', '-', 'null:',
] ]
log.debug('comparing images with pipeline {} | {}', log.debug('comparing images with pipeline {} | {}',
@ -373,7 +464,7 @@ def im_compare(artresizer, im1, im2, compare_threshold):
return phash_diff <= compare_threshold return phash_diff <= compare_threshold
def pil_compare(artresizer, im1, im2, compare_threshold): def pil_compare(backend, im1, im2, compare_threshold):
# It is an error to call this when ArtResizer.can_compare is not True. # It is an error to call this when ArtResizer.can_compare is not True.
raise NotImplementedError() raise NotImplementedError()
@ -409,22 +500,11 @@ class ArtResizer(metaclass=Shareable):
def __init__(self): def __init__(self):
"""Create a resizer object with an inferred method. """Create a resizer object with an inferred method.
""" """
self.method = self._check_method() self.local_method = self._check_method()
log.debug("artresizer: method is {0}", self.method) if self.local_method is None:
log.debug(f"artresizer: method is WEBPROXY")
# Use ImageMagick's magick binary when it's available. If it's else:
# not, fall back to the older, separate convert and identify log.debug(f"artresizer: method is {self.local_method.NAME}")
# commands.
if self.method[0] == IMAGEMAGICK:
self.im_legacy = self.method[2]
if self.im_legacy:
self.im_convert_cmd = ['convert']
self.im_identify_cmd = ['identify']
self.im_compare_cmd = ['compare']
else:
self.im_convert_cmd = ['magick']
self.im_identify_cmd = ['magick', 'identify']
self.im_compare_cmd = ['magick', 'compare']
def resize( def resize(
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0 self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
@ -435,8 +515,8 @@ class ArtResizer(metaclass=Shareable):
For WEBPROXY, returns `path_in` unmodified. For WEBPROXY, returns `path_in` unmodified.
""" """
if self.local: if self.local:
func = BACKEND_FUNCS[self.method[0]] func = BACKEND_FUNCS[self.local_method]
return func(self, maxwidth, path_in, path_out, return func(self.local_method, maxwidth, path_in, path_out,
quality=quality, max_filesize=max_filesize) quality=quality, max_filesize=max_filesize)
else: else:
# Handled by `proxy_url` already. # Handled by `proxy_url` already.
@ -448,8 +528,8 @@ class ArtResizer(metaclass=Shareable):
Only available locally. Only available locally.
""" """
if self.local: if self.local:
func = DEINTERLACE_FUNCS[self.method[0]] func = DEINTERLACE_FUNCS[self.local_method.ID]
return func(self, path_in, path_out) return func(self.local_method, path_in, path_out)
else: else:
# FIXME: Should probably issue a warning? # FIXME: Should probably issue a warning?
return path_in return path_in
@ -470,7 +550,7 @@ class ArtResizer(metaclass=Shareable):
"""A boolean indicating whether the resizing method is performed """A boolean indicating whether the resizing method is performed
locally (i.e., PIL or ImageMagick). locally (i.e., PIL or ImageMagick).
""" """
return self.method[0] in BACKEND_FUNCS return self.local_method is not None
def get_size(self, path_in): def get_size(self, path_in):
"""Return the size of an image file as an int couple (width, height) """Return the size of an image file as an int couple (width, height)
@ -479,11 +559,11 @@ class ArtResizer(metaclass=Shareable):
Only available locally. Only available locally.
""" """
if self.local: if self.local:
func = BACKEND_GET_SIZE[self.method[0]] func = BACKEND_GET_SIZE[self.local_method.ID]
return func(self, path_in) return func(self.local_method, path_in)
else: else:
# FIXME: Should probably issue a warning? # FIXME: Should probably issue a warning?
return None return path_in
def get_format(self, path_in): def get_format(self, path_in):
"""Returns the format of the image as a string. """Returns the format of the image as a string.
@ -491,8 +571,8 @@ class ArtResizer(metaclass=Shareable):
Only available locally. Only available locally.
""" """
if self.local: if self.local:
func = BACKEND_GET_FORMAT[self.method[0]] func = BACKEND_GET_FORMAT[self.local_method.ID]
return func(self, path_in) return func(self.local_method, path_in)
else: else:
# FIXME: Should probably issue a warning? # FIXME: Should probably issue a warning?
return None return None
@ -503,7 +583,7 @@ class ArtResizer(metaclass=Shareable):
Only available locally. Only available locally.
""" """
if not self.local: if self.local:
# FIXME: Should probably issue a warning? # FIXME: Should probably issue a warning?
return path_in return path_in
@ -515,13 +595,13 @@ class ArtResizer(metaclass=Shareable):
fname, ext = os.path.splitext(path_in) fname, ext = os.path.splitext(path_in)
path_new = fname + b'.' + new_format.encode('utf8') path_new = fname + b'.' + new_format.encode('utf8')
func = BACKEND_CONVERT_IMAGE_FORMAT[self.method[0]] func = BACKEND_CONVERT_IMAGE_FORMAT[self.local_method.ID]
# allows the exception to propagate, while still making sure a changed # allows the exception to propagate, while still making sure a changed
# file path was removed # file path was removed
result_path = path_in result_path = path_in
try: try:
result_path = func(self, path_in, path_new, deinterlaced) result_path = func(self.local_method, path_in, path_new, deinterlaced)
finally: finally:
if result_path != path_in: if result_path != path_in:
os.unlink(path_in) os.unlink(path_in)
@ -531,7 +611,11 @@ class ArtResizer(metaclass=Shareable):
def can_compare(self): def can_compare(self):
"""A boolean indicating whether image comparison is available""" """A boolean indicating whether image comparison is available"""
return self.method[0] == IMAGEMAGICK and self.method[1] > (6, 8, 7) return (
self.local
and self.local_method.ID == IMAGEMAGICK
and self.local_method.version() > (6, 8, 7)
)
def compare(self, im1, im2, compare_threshold): def compare(self, im1, im2, compare_threshold):
"""Return a boolean indicating whether two images are similar. """Return a boolean indicating whether two images are similar.
@ -539,65 +623,23 @@ class ArtResizer(metaclass=Shareable):
Only available locally. Only available locally.
""" """
if self.local: if self.local:
func = BACKEND_COMPARE[self.method[0]] func = BACKEND_COMPARE[self.local_method.ID]
return func(self, im1, im2, compare_threshold) return func(self.local_method, im1, im2, compare_threshold)
else: else:
# FIXME: Should probably issue a warning? # FIXME: Should probably issue a warning?
return None return None
@staticmethod @staticmethod
def _check_method(): def _check_method():
"""Return a tuple indicating an available method and its version. """Search availabe methods.
The result has at least two elements: If a local backend is availabe, return an instance of the backend
- The method, eitehr WEBPROXY, PIL, or IMAGEMAGICK. class. Otherwise, when fallback to the web proxy is requird, return
- The version. None.
If the method is IMAGEMAGICK, there is also a third element: a
bool flag indicating whether to use the `magick` binary or
legacy single-purpose executables (`convert`, `identify`, etc.)
""" """
version = get_im_version()
if version:
version, legacy = version
return IMAGEMAGICK, version, legacy
version = get_pil_version()
if version:
return PIL, version
return WEBPROXY, (0)
def get_im_version():
"""Get the ImageMagick version and legacy flag as a pair. Or return
None if ImageMagick is not available.
"""
for cmd_name, legacy in ((['magick'], False), (['convert'], True)):
cmd = cmd_name + ['--version']
try: try:
out = util.command_output(cmd).stdout return IMBackend()
except (subprocess.CalledProcessError, OSError) as exc: return PILBackend()
log.debug('ImageMagick version check failed: {}', exc) except LocalBackendNotAvailableError:
else: return None
if b'imagemagick' in out.lower():
pattern = br".+ (\d+)\.(\d+)\.(\d+).*"
match = re.search(pattern, out)
if match:
version = (int(match.group(1)),
int(match.group(2)),
int(match.group(3)))
return version, legacy
return None
def get_pil_version():
"""Get the PIL/Pillow version, or None if it is unavailable.
"""
try:
__import__('PIL', fromlist=['Image'])
return (0,)
except ImportError:
return None

View file

@ -32,7 +32,7 @@ from xdg import BaseDirectory
from beets.plugins import BeetsPlugin from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, decargs from beets.ui import Subcommand, decargs
from beets import util from beets import util
from beets.util.artresizer import ArtResizer, get_im_version, get_pil_version from beets.util.artresizer import ArtResizer, IMBackend, PILBackend
BASE_DIR = os.path.join(BaseDirectory.xdg_cache_home, "thumbnails") BASE_DIR = os.path.join(BaseDirectory.xdg_cache_home, "thumbnails")
@ -90,14 +90,18 @@ class ThumbnailsPlugin(BeetsPlugin):
if not os.path.exists(dir): if not os.path.exists(dir):
os.makedirs(dir) os.makedirs(dir)
if get_im_version(): # FIXME: Should we have our own backend instance?
self.backend = ArtResizer.shared.local_method
if isinstance(self.backend, IMBackend):
self.write_metadata = write_metadata_im self.write_metadata = write_metadata_im
tool = "IM" elif isinstance(self.backend, PILBackend):
else:
assert get_pil_version() # since we're local
self.write_metadata = write_metadata_pil self.write_metadata = write_metadata_pil
tool = "PIL" else:
self._log.debug("using {0} to write metadata", tool) # since we're local
raise RuntimeError(
f"Thumbnails: Unexpected ArtResizer backend {self.backend!r}."
)
self._log.debug(f"using {self.backend.NAME} to write metadata")
uri_getter = GioURI() uri_getter = GioURI()
if not uri_getter.available: if not uri_getter.available:
@ -171,7 +175,7 @@ class ThumbnailsPlugin(BeetsPlugin):
metadata = {"Thumb::URI": self.get_uri(album.artpath), metadata = {"Thumb::URI": self.get_uri(album.artpath),
"Thumb::MTime": str(mtime)} "Thumb::MTime": str(mtime)}
try: try:
self.write_metadata(image_path, metadata) self.write_metadata(self.backend, image_path, metadata)
except Exception: except Exception:
self._log.exception("could not write metadata to {0}", self._log.exception("could not write metadata to {0}",
util.displayable_path(image_path)) util.displayable_path(image_path))
@ -188,16 +192,16 @@ class ThumbnailsPlugin(BeetsPlugin):
self._log.debug("Wrote file {0}", util.displayable_path(outfilename)) self._log.debug("Wrote file {0}", util.displayable_path(outfilename))
def write_metadata_im(file, metadata): def write_metadata_im(im_backend, file, metadata):
"""Enrich the file metadata with `metadata` dict thanks to IM.""" """Enrich the file metadata with `metadata` dict thanks to IM."""
command = ['convert', file] + \ command = im_backend.convert_cmd + [file] + \
list(chain.from_iterable(('-set', k, v) list(chain.from_iterable(('-set', k, v)
for k, v in metadata.items())) + [file] for k, v in metadata.items())) + [file]
util.command_output(command) util.command_output(command)
return True return True
def write_metadata_pil(file, metadata): def write_metadata_pil(pil_backend, file, metadata):
"""Enrich the file metadata with `metadata` dict thanks to PIL.""" """Enrich the file metadata with `metadata` dict thanks to PIL."""
from PIL import Image, PngImagePlugin from PIL import Image, PngImagePlugin
im = Image.open(file) im = Image.open(file)

View file

@ -31,7 +31,7 @@ from beets import library
from beets import importer from beets import importer
from beets import logging from beets import logging
from beets import util from beets import util
from beets.util.artresizer import ArtResizer, WEBPROXY from beets.util.artresizer import ArtResizer
import confuse import confuse
@ -787,7 +787,7 @@ class ArtForAlbumTest(UseThePlugin):
"""Skip the test if the art resizer doesn't have ImageMagick or """Skip the test if the art resizer doesn't have ImageMagick or
PIL (so comparisons and measurements are unavailable). PIL (so comparisons and measurements are unavailable).
""" """
if ArtResizer.shared.method[0] == WEBPROXY: if not ArtResizer.shared.local:
self.skipTest("ArtResizer has no local imaging backend available") self.skipTest("ArtResizer has no local imaging backend available")
def test_respect_minwidth(self): def test_respect_minwidth(self):

View file

@ -22,16 +22,34 @@ from test import _common
from test.helper import TestHelper from test.helper import TestHelper
from beets.util import command_output, syspath from beets.util import command_output, syspath
from beets.util.artresizer import ( from beets.util.artresizer import (
IMBackend,
PILBackend,
pil_resize, pil_resize,
im_resize, im_resize,
get_im_version,
get_pil_version,
pil_deinterlace, pil_deinterlace,
im_deinterlace, im_deinterlace,
ArtResizer,
) )
class DummyIMBackend(IMBackend):
"""An `IMBackend` which pretends that ImageMagick is available, and has
a sufficiently recent version to support image comparison.
"""
def __init__(self):
self.version = (7, 0, 0)
self.legacy = False
self.convert_cmd = ['magick']
self.identify_cmd = ['magick', 'identify']
self.compare_cmd = ['magick', 'compare']
class DummyPILBackend(PILBackend):
"""An `PILBackend` which pretends that PIL is available.
"""
def __init__(self):
pass
class ArtResizerFileSizeTest(_common.TestCase, TestHelper): class ArtResizerFileSizeTest(_common.TestCase, TestHelper):
"""Unittest test case for Art Resizer to a specific filesize.""" """Unittest test case for Art Resizer to a specific filesize."""
@ -46,11 +64,11 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper):
"""Called after each test, unloading all plugins.""" """Called after each test, unloading all plugins."""
self.teardown_beets() self.teardown_beets()
def _test_img_resize(self, resize_func): def _test_img_resize(self, backend, resize_func):
"""Test resizing based on file size, given a resize_func.""" """Test resizing based on file size, given a resize_func."""
# Check quality setting unaffected by new parameter # Check quality setting unaffected by new parameter
im_95_qual = resize_func( im_95_qual = resize_func(
ArtResizer.shared, backend,
225, 225,
self.IMG_225x225, self.IMG_225x225,
quality=95, quality=95,
@ -61,7 +79,7 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper):
# Attempt a lower filesize with same quality # Attempt a lower filesize with same quality
im_a = resize_func( im_a = resize_func(
ArtResizer.shared, backend,
225, 225,
self.IMG_225x225, self.IMG_225x225,
quality=95, quality=95,
@ -74,7 +92,7 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper):
# Attempt with lower initial quality # Attempt with lower initial quality
im_75_qual = resize_func( im_75_qual = resize_func(
ArtResizer.shared, backend,
225, 225,
self.IMG_225x225, self.IMG_225x225,
quality=75, quality=75,
@ -83,7 +101,7 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper):
self.assertExists(im_75_qual) self.assertExists(im_75_qual)
im_b = resize_func( im_b = resize_func(
ArtResizer.shared, backend,
225, 225,
self.IMG_225x225, self.IMG_225x225,
quality=95, quality=95,
@ -94,37 +112,38 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper):
self.assertLess(os.stat(syspath(im_b)).st_size, self.assertLess(os.stat(syspath(im_b)).st_size,
os.stat(syspath(im_75_qual)).st_size) os.stat(syspath(im_75_qual)).st_size)
@unittest.skipUnless(get_pil_version(), "PIL not available") @unittest.skipUnless(PILBackend.available(), "PIL not available")
def test_pil_file_resize(self): def test_pil_file_resize(self):
"""Test PIL resize function is lowering file size.""" """Test PIL resize function is lowering file size."""
self._test_img_resize(pil_resize) self._test_img_resize(PILBackend(), pil_resize)
@unittest.skipUnless(get_im_version(), "ImageMagick not available") @unittest.skipUnless(IMBackend.available(), "ImageMagick not available")
def test_im_file_resize(self): def test_im_file_resize(self):
"""Test IM resize function is lowering file size.""" """Test IM resize function is lowering file size."""
self._test_img_resize(im_resize) self._test_img_resize(IMBackend(), im_resize)
@unittest.skipUnless(get_pil_version(), "PIL not available") @unittest.skipUnless(PILBackend.available(), "PIL not available")
def test_pil_file_deinterlace(self): def test_pil_file_deinterlace(self):
"""Test PIL deinterlace function. """Test PIL deinterlace function.
Check if pil_deinterlace function returns images Check if pil_deinterlace function returns images
that are non-progressive that are non-progressive
""" """
path = pil_deinterlace(ArtResizer.shared, self.IMG_225x225) path = pil_deinterlace(PILBackend(), self.IMG_225x225)
from PIL import Image from PIL import Image
with Image.open(path) as img: with Image.open(path) as img:
self.assertFalse('progression' in img.info) self.assertFalse('progression' in img.info)
@unittest.skipUnless(get_im_version(), "ImageMagick not available") @unittest.skipUnless(IMBackend.available(), "ImageMagick not available")
def test_im_file_deinterlace(self): def test_im_file_deinterlace(self):
"""Test ImageMagick deinterlace function. """Test ImageMagick deinterlace function.
Check if im_deinterlace function returns images Check if im_deinterlace function returns images
that are non-progressive. that are non-progressive.
""" """
path = im_deinterlace(ArtResizer.shared, self.IMG_225x225) im = IMBackend()
cmd = ArtResizer.shared.im_identify_cmd + [ path = im_deinterlace(im, self.IMG_225x225)
cmd = im.identify_cmd + [
'-format', '%[interlace]', syspath(path, prefix=False), '-format', '%[interlace]', syspath(path, prefix=False),
] ]
out = command_output(cmd).stdout out = command_output(cmd).stdout

View file

@ -21,6 +21,7 @@ import unittest
from test import _common from test import _common
from test.helper import TestHelper from test.helper import TestHelper
from test.test_art_resize import DummyIMBackend
from mediafile import MediaFile from mediafile import MediaFile
from beets import config, logging, ui from beets import config, logging, ui
@ -220,9 +221,8 @@ class DummyArtResizer(ArtResizer):
"""An `ArtResizer` which pretends that ImageMagick is available, and has """An `ArtResizer` which pretends that ImageMagick is available, and has
a sufficiently recent version to support image comparison. a sufficiently recent version to support image comparison.
""" """
@staticmethod def __init__(self):
def _check_method(): self.local_method = DummyIMBackend()
return artresizer.IMAGEMAGICK, (7, 0, 0), True
@patch('beets.util.artresizer.subprocess') @patch('beets.util.artresizer.subprocess')

View file

@ -20,6 +20,7 @@ from shutil import rmtree
import unittest import unittest
from test.helper import TestHelper from test.helper import TestHelper
from test.test_art_resize import DummyIMBackend, DummyPILBackend
from beets.util import bytestring_path from beets.util import bytestring_path
from beetsplug.thumbnails import (ThumbnailsPlugin, NORMAL_DIR, LARGE_DIR, from beetsplug.thumbnails import (ThumbnailsPlugin, NORMAL_DIR, LARGE_DIR,
@ -37,18 +38,21 @@ class ThumbnailsTest(unittest.TestCase, TestHelper):
@patch('beetsplug.thumbnails.util') @patch('beetsplug.thumbnails.util')
def test_write_metadata_im(self, mock_util): def test_write_metadata_im(self, mock_util):
metadata = {"a": "A", "b": "B"} metadata = {"a": "A", "b": "B"}
write_metadata_im("foo", metadata) im = DummyIMBackend()
write_metadata_im(im, "foo", metadata)
try: try:
command = "convert foo -set a A -set b B foo".split(' ') command = im.convert_cmd + "foo -set a A -set b B foo".split()
mock_util.command_output.assert_called_once_with(command) mock_util.command_output.assert_called_once_with(command)
except AssertionError: except AssertionError:
command = "convert foo -set b B -set a A foo".split(' ') command = im.convert_cmd + "foo -set b B -set a A foo".split()
mock_util.command_output.assert_called_once_with(command) mock_util.command_output.assert_called_once_with(command)
@patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok') @patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok')
@patch('beetsplug.thumbnails.os.stat') @patch('beetsplug.thumbnails.os.stat')
def test_add_tags(self, mock_stat, _): def test_add_tags(self, mock_stat, _):
plugin = ThumbnailsPlugin() plugin = ThumbnailsPlugin()
# backend is not set due to _check_local_ok being mocked
plugin.backend = "DummyBackend"
plugin.write_metadata = Mock() plugin.write_metadata = Mock()
plugin.get_uri = Mock(side_effect={b"/path/to/cover": plugin.get_uri = Mock(side_effect={b"/path/to/cover":
"COVER_URI"}.__getitem__) "COVER_URI"}.__getitem__)
@ -59,24 +63,26 @@ class ThumbnailsTest(unittest.TestCase, TestHelper):
metadata = {"Thumb::URI": "COVER_URI", metadata = {"Thumb::URI": "COVER_URI",
"Thumb::MTime": "12345"} "Thumb::MTime": "12345"}
plugin.write_metadata.assert_called_once_with(b"/path/to/thumbnail", plugin.write_metadata.assert_called_once_with(
metadata) plugin.backend,
b"/path/to/thumbnail",
metadata,
)
mock_stat.assert_called_once_with(album.artpath) mock_stat.assert_called_once_with(album.artpath)
@patch('beetsplug.thumbnails.os') @patch('beetsplug.thumbnails.os')
@patch('beetsplug.thumbnails.ArtResizer') @patch('beetsplug.thumbnails.ArtResizer')
@patch('beetsplug.thumbnails.get_im_version')
@patch('beetsplug.thumbnails.get_pil_version')
@patch('beetsplug.thumbnails.GioURI') @patch('beetsplug.thumbnails.GioURI')
def test_check_local_ok(self, mock_giouri, mock_pil, mock_im, def test_check_local_ok(self, mock_giouri, mock_artresizer, mock_os):
mock_artresizer, mock_os):
# test local resizing capability # test local resizing capability
mock_artresizer.shared.local = False mock_artresizer.shared.local = False
mock_artresizer.shared.local_method = None
plugin = ThumbnailsPlugin() plugin = ThumbnailsPlugin()
self.assertFalse(plugin._check_local_ok()) self.assertFalse(plugin._check_local_ok())
# test dirs creation # test dirs creation
mock_artresizer.shared.local = True mock_artresizer.shared.local = True
mock_artresizer.shared.local_method = DummyIMBackend()
def exists(path): def exists(path):
if path == NORMAL_DIR: if path == NORMAL_DIR:
@ -91,18 +97,18 @@ class ThumbnailsTest(unittest.TestCase, TestHelper):
# test metadata writer function # test metadata writer function
mock_os.path.exists = lambda _: True mock_os.path.exists = lambda _: True
mock_pil.return_value = False
mock_im.return_value = False mock_artresizer.shared.local = True
with self.assertRaises(AssertionError): mock_artresizer.shared.local_method = None
with self.assertRaises(RuntimeError):
ThumbnailsPlugin() ThumbnailsPlugin()
mock_pil.return_value = True mock_artresizer.shared.local = True
mock_artresizer.shared.local_method = DummyPILBackend()
self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_pil) self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_pil)
mock_im.return_value = True mock_artresizer.shared.local = True
self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_im) mock_artresizer.shared.local_method = DummyIMBackend()
mock_pil.return_value = False
self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_im) self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_im)
self.assertTrue(ThumbnailsPlugin()._check_local_ok()) self.assertTrue(ThumbnailsPlugin()._check_local_ok())