Repository: beetbox/beets

Issue:
Image similarity comparison should use modern `magick` binary when available
Beets is having intermittent issues with the latest version of Imagemagick as it seems the commands used to call compare,convert, etc have changed in the latest verison IM 7.  in a cmd prompt running 'Magick compare' produces output as pictured below. 'Magick' was not required in previous versions. 


![2022-05-02-23_28_50-VMHost - VMHost - Remote Desktop Connection](https://user-images.githubusercontent.com/90217713/152687778-077b3532-ea3b-4721-8551-546fc5d9a927.png)

```
lyrics: fetched lyrics: Prince - 3121 - 3121
Sending event: art_set
embedart: Embedding album art into Otep - KULT 45
Sending event: database_change
embedart: Extracting album art from: Otep - KULT 45 - Hail to the Thief to: C:\Users\ANDREW~1\AppData\Local\Temp\tmpj8j8woil.jpg
embedart: comparing images with pipeline ['convert', 'F:\\Download Queues\\Complete\\Music Library Temp\\Artists\\Otep\\2018 - KULT 45\\thumb.jpg', 'C:\\Users\\ANDREW~1\\AppData\\Local\\Temp\\tmpj8j8woil.jpg', '-colorspace', 'gray', 'MIFF:-'] | ['compare', '-metric', 'PHASH', '-', 'null:']
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Lolita (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Lolita
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Te-Amo-Coraz%C3%B3n (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Te Amo Corazón
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Black-Sweat (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Black Sweat
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Incense-And-Candles (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Incense and Candles
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Love (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Love
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Satisfied (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Satisfied
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Fury (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Fury
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/The-Word (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - The Word
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Beautiful%2C-Loved-And-Blessed (404)lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Beautiful, Loved and Blessed
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/The-Dance (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - The Dance
Sending event: database_change
lyrics: failed to fetch: https://www.musixmatch.com/lyrics/Prince/Get-On-The-Boat (404)
lyrics: Received unusual song page html
lyrics: got lyrics from backend: Genius
lyrics: fetched lyrics: Prince - 3121 - Get on the Boat
Sending event: database_change
Traceback (most recent call last):
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\Scripts\beet-script.py", line 33, in <module>
    sys.exit(load_entry_point('beets==1.6.1', 'console_scripts', 'beet')())
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\ui\__init__.py", line 1285, in main
    _raw_main(args)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\ui\__init__.py", line 1272, in _raw_main
    subcommand.func(lib, suboptions, subargs)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\ui\commands.py", line 973, in import_func
    import_files(lib, paths, query)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\ui\commands.py", line 943, in import_files
    session.run()
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\importer.py", line 340, in run
    pl.run_parallel(QUEUE_SIZE)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\util\pipeline.py", line 446, in run_parallel
    raise exc_info[1].with_traceback(exc_info[2])
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\util\pipeline.py", line 358, in run
    self.coro.send(msg)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\util\pipeline.py", line 170, in coro
    task = func(*(args + (task,)))
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\importer.py", line 1566, in manipulate_files
    task.manipulate_files(
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\importer.py", line 766, in manipulate_files
    plugins.send('import_task_files', session=session, task=self)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\plugins.py", line 488, in send
    result = handler(**arguments)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\plugins.py", line 145, in wrapper
    return func(*args, **kwargs)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beetsplug\fetchart.py", line 1063, in assign_art
    self._set_art(task.album, candidate, not self.src_removed)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beetsplug\fetchart.py", line 1048, in _set_art
    album.set_art(candidate.path, delete)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\library.py", line 1340, in set_art
    plugins.send('art_set', album=self)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\plugins.py", line 488, in send
    result = handler(**arguments)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\plugins.py", line 145, in wrapper
    return func(*args, **kwargs)
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beetsplug\embedart.py", line 187, in process_album
    art.embed_album(self._log, album, max_width, True,
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\art.py", line 103, in embed_album
    embed_item(log, item, imagepath, maxwidth, None, compare_threshold,
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\art.py", line 58, in embed_item
    if not check_art_similarity(log, item, imagepath, compare_threshold):
  File "C:\Users\Andrew Sutcliffe\AppData\Roaming\Python\Python310\site-packages\beets\art.py", line 144, in check_art_similarity
    compare_proc = subprocess.Popen(
  File "C:\Program Files\Python310\lib\subprocess.py", line 966, in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
  File "C:\Program Files\Python310\lib\subprocess.py", line 1435, in _execute_child
    hp, ht, pid, tid = _winapi.CreateProcess(executable, args,
FileNotFoundError: [WinError 2] The system cannot find the file specified
```



Discussion:

Patch:
diff --git a/beets/art.py b/beets/art.py
index 13d5dfbd4..6e0a5f82b 100644
--- a/beets/art.py
+++ b/beets/art.py
@@ -17,8 +17,6 @@ music and items' embedded album art.
 """
 
 
-import subprocess
-import platform
 from tempfile import NamedTemporaryFile
 import os
 
@@ -53,14 +51,22 @@ def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
                quality=0):
     """Embed an image into the item's media file.
     """
-    # Conditions and filters.
+    # Conditions.
     if compare_threshold:
-        if not check_art_similarity(log, item, imagepath, compare_threshold):
+        is_similar = check_art_similarity(
+            log, item, imagepath, compare_threshold)
+        if is_similar is None:
+            log.warning('Error while checking art similarity; skipping.')
+            return
+        elif not is_similar:
             log.info('Image not similar; skipping.')
             return
+
     if ifempty and get_art(log, item):
         log.info('media file already contained art')
         return
+
+    # Filters.
     if maxwidth and not as_album:
         imagepath = resize_image(log, imagepath, maxwidth, quality)
 
@@ -115,76 +121,30 @@ def resize_image(log, imagepath, maxwidth, quality):
     return imagepath
 
 
-def check_art_similarity(log, item, imagepath, compare_threshold):
+def check_art_similarity(
+    log,
+    item,
+    imagepath,
+    compare_threshold,
+    artresizer=None,
+):
     """A boolean indicating if an image is similar to embedded item art.
+
+    If no embedded art exists, always return `True`. If the comparison fails
+    for some reason, the return value is `None`.
+
+    This must only be called if `ArtResizer.shared.can_compare` is `True`.
     """
     with NamedTemporaryFile(delete=True) as f:
         art = extract(log, f.name, item)
 
-        if art:
-            is_windows = platform.system() == "Windows"
-
-            # Converting images to grayscale tends to minimize the weight
-            # of colors in the diff score. So we first convert both images
-            # to grayscale and then pipe them into the `compare` command.
-            # On Windows, ImageMagick doesn't support the magic \\?\ prefix
-            # on paths, so we pass `prefix=False` to `syspath`.
-            convert_cmd = ['convert', syspath(imagepath, prefix=False),
-                           syspath(art, prefix=False),
-                           '-colorspace', 'gray', 'MIFF:-']
-            compare_cmd = ['compare', '-metric', 'PHASH', '-', 'null:']
-            log.debug('comparing images with pipeline {} | {}',
-                      convert_cmd, compare_cmd)
-            convert_proc = subprocess.Popen(
-                convert_cmd,
-                stdout=subprocess.PIPE,
-                stderr=subprocess.PIPE,
-                close_fds=not is_windows,
-            )
-            compare_proc = subprocess.Popen(
-                compare_cmd,
-                stdin=convert_proc.stdout,
-                stdout=subprocess.PIPE,
-                stderr=subprocess.PIPE,
-                close_fds=not is_windows,
-            )
-
-            # Check the convert output. We're not interested in the
-            # standard output; that gets piped to the next stage.
-            convert_proc.stdout.close()
-            convert_stderr = convert_proc.stderr.read()
-            convert_proc.stderr.close()
-            convert_proc.wait()
-            if convert_proc.returncode:
-                log.debug(
-                    'ImageMagick convert failed with status {}: {!r}',
-                    convert_proc.returncode,
-                    convert_stderr,
-                )
-                return
-
-            # Check the compare output.
-            stdout, stderr = compare_proc.communicate()
-            if compare_proc.returncode:
-                if compare_proc.returncode != 1:
-                    log.debug('ImageMagick compare failed: {0}, {1}',
-                              displayable_path(imagepath),
-                              displayable_path(art))
-                    return
-                out_str = stderr
-            else:
-                out_str = stdout
-
-            try:
-                phash_diff = float(out_str)
-            except ValueError:
-                log.debug('IM output is not a number: {0!r}', out_str)
-                return
-
-            log.debug('ImageMagick compare score: {0}', phash_diff)
-            return phash_diff <= compare_threshold
-
-    return True
+        if not art:
+            return True
+
+        if artresizer is None:
+            artresizer = ArtResizer.shared
+
+        return artresizer.compare(art, imagepath, compare_threshold)
 
 
 def extract(log, outpath, item):
diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py
index 8683e2287..0041db230 100644
--- a/beets/util/artresizer.py
+++ b/beets/util/artresizer.py
@@ -19,11 +19,13 @@ public resizing proxy if neither is available.
 import subprocess
 import os
 import os.path
+import platform
 import re
 from tempfile import NamedTemporaryFile
 from urllib.parse import urlencode
 from beets import logging
 from beets import util
+from beets.util import bytestring_path, displayable_path, py3_path, syspath
 
 # Resizing methods
 PIL = 1
@@ -55,11 +57,12 @@ def temp_file_for(path):
     specified path.
     """
     ext = os.path.splitext(path)[1]
-    with NamedTemporaryFile(suffix=util.py3_path(ext), delete=False) as f:
-        return util.bytestring_path(f.name)
+    with NamedTemporaryFile(suffix=py3_path(ext), delete=False) as f:
+        return bytestring_path(f.name)
 
 
-def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
+def pil_resize(artresizer, maxwidth, path_in, path_out=None, quality=0,
+               max_filesize=0):
     """Resize using Python Imaging Library (PIL).  Return the output path
     of resized image.
     """
@@ -67,10 +70,10 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
     from PIL import Image
 
     log.debug('artresizer: PIL resizing {0} to {1}',
-              util.displayable_path(path_in), util.displayable_path(path_out))
+              displayable_path(path_in), displayable_path(path_out))
 
     try:
-        im = Image.open(util.syspath(path_in))
+        im = Image.open(syspath(path_in))
         size = maxwidth, maxwidth
         im.thumbnail(size, Image.ANTIALIAS)
 
@@ -80,7 +83,7 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
 
         # progressive=False only affects JPEGs and is the default,
         # but we include it here for explicitness.
-        im.save(util.py3_path(path_out), quality=quality, progressive=False)
+        im.save(py3_path(path_out), quality=quality, progressive=False)
 
         if max_filesize > 0:
             # If maximum filesize is set, we attempt to lower the quality of
@@ -92,7 +95,7 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
                 lower_qual = 95
             for i in range(5):
                 # 5 attempts is an abitrary choice
-                filesize = os.stat(util.syspath(path_out)).st_size
+                filesize = os.stat(syspath(path_out)).st_size
                 log.debug("PIL Pass {0} : Output size: {1}B", i, filesize)
                 if filesize <= max_filesize:
                     return path_out
@@ -103,7 +106,7 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
                 if lower_qual < 10:
                     lower_qual = 10
                 # Use optimize flag to improve filesize decrease
-                im.save(util.py3_path(path_out), quality=lower_qual,
+                im.save(py3_path(path_out), quality=lower_qual,
                         optimize=True, progressive=False)
             log.warning("PIL Failed to resize file to below {0}B",
                         max_filesize)
@@ -113,11 +116,12 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
             return path_out
     except OSError:
         log.error("PIL cannot create thumbnail for '{0}'",
-                  util.displayable_path(path_in))
+                  displayable_path(path_in))
         return path_in
 
 
-def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
+def im_resize(artresizer, maxwidth, path_in, path_out=None, quality=0,
+              max_filesize=0):
     """Resize using ImageMagick.
 
     Use the ``magick`` program or ``convert`` on older versions. Return
@@ -125,15 +129,15 @@ def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
     """
     path_out = path_out or temp_file_for(path_in)
     log.debug('artresizer: ImageMagick resizing {0} to {1}',
-              util.displayable_path(path_in), util.displayable_path(path_out))
+              displayable_path(path_in), displayable_path(path_out))
 
     # "-resize WIDTHx>" shrinks images with the width larger
     # than the given width while maintaining the aspect ratio
     # with regards to the height.
     # ImageMagick already seems to default to no interlace, but we include it
     # here for the sake of explicitness.
-    cmd = ArtResizer.shared.im_convert_cmd + [
-        util.syspath(path_in, prefix=False),
+    cmd = artresizer.im_convert_cmd + [
+        syspath(path_in, prefix=False),
         '-resize', f'{maxwidth}x>',
         '-interlace', 'none',
     ]
@@ -146,13 +150,13 @@ def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
     if max_filesize > 0:
         cmd += ['-define', f'jpeg:extent={max_filesize}b']
 
-    cmd.append(util.syspath(path_out, prefix=False))
+    cmd.append(syspath(path_out, prefix=False))
 
     try:
         util.command_output(cmd)
     except subprocess.CalledProcessError:
         log.warning('artresizer: IM convert failed for {0}',
-                    util.displayable_path(path_in))
+                    displayable_path(path_in))
         return path_in
 
     return path_out
@@ -164,20 +168,21 @@ BACKEND_FUNCS = {
 }
 
 
-def pil_getsize(path_in):
+def pil_getsize(artresizer, path_in):
     from PIL import Image
 
     try:
-        im = Image.open(util.syspath(path_in))
+        im = Image.open(syspath(path_in))
         return im.size
     except OSError as exc:
         log.error("PIL could not read file {}: {}",
-                  util.displayable_path(path_in), exc)
+                  displayable_path(path_in), exc)
+        return None
 
 
-def im_getsize(path_in):
-    cmd = ArtResizer.shared.im_identify_cmd + \
-        ['-format', '%w %h', util.syspath(path_in, prefix=False)]
+def im_getsize(artresizer, path_in):
+    cmd = artresizer.im_identify_cmd + \
+        ['-format', '%w %h', syspath(path_in, prefix=False)]
 
     try:
         out = util.command_output(cmd).stdout
@@ -188,11 +193,12 @@ def im_getsize(path_in):
             'getting size with command {}:\n{}',
             exc.returncode, cmd, exc.output.strip()
         )
-        return
+        return None
     try:
         return tuple(map(int, out.split(b' ')))
     except IndexError:
         log.warning('Could not understand IM output: {0!r}', out)
+        return None
 
 
 BACKEND_GET_SIZE = {
@@ -201,25 +207,25 @@ BACKEND_GET_SIZE = {
 }
 
 
-def pil_deinterlace(path_in, path_out=None):
+def pil_deinterlace(artresizer, path_in, path_out=None):
     path_out = path_out or temp_file_for(path_in)
     from PIL import Image
 
     try:
-        im = Image.open(util.syspath(path_in))
-        im.save(util.py3_path(path_out), progressive=False)
+        im = Image.open(syspath(path_in))
+        im.save(py3_path(path_out), progressive=False)
         return path_out
     except IOError:
         return path_in
 
 
-def im_deinterlace(path_in, path_out=None):
+def im_deinterlace(artresizer, path_in, path_out=None):
     path_out = path_out or temp_file_for(path_in)
 
-    cmd = ArtResizer.shared.im_convert_cmd + [
-        util.syspath(path_in, prefix=False),
+    cmd = artresizer.im_convert_cmd + [
+        syspath(path_in, prefix=False),
         '-interlace', 'none',
-        util.syspath(path_out, prefix=False),
+        syspath(path_out, prefix=False),
     ]
 
     try:
@@ -235,10 +241,10 @@ DEINTERLACE_FUNCS = {
 }
 
 
-def im_get_format(filepath):
-    cmd = ArtResizer.shared.im_identify_cmd + [
+def im_get_format(artresizer, filepath):
+    cmd = artresizer.im_identify_cmd + [
         '-format', '%[magick]',
-        util.syspath(filepath)
+        syspath(filepath)
     ]
 
     try:
@@ -247,11 +253,11 @@ def im_get_format(filepath):
         return None
 
 
-def pil_get_format(filepath):
+def pil_get_format(artresizer, filepath):
     from PIL import Image, UnidentifiedImageError
 
     try:
-        with Image.open(util.syspath(filepath)) as im:
+        with Image.open(syspath(filepath)) as im:
             return im.format
     except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError):
         log.exception("failed to detect image format for {}", filepath)
@@ -264,11 +270,11 @@ BACKEND_GET_FORMAT = {
 }
 
 
-def im_convert_format(source, target, deinterlaced):
-    cmd = ArtResizer.shared.im_convert_cmd + [
-        util.syspath(source),
+def im_convert_format(artresizer, source, target, deinterlaced):
+    cmd = artresizer.im_convert_cmd + [
+        syspath(source),
         *(["-interlace", "none"] if deinterlaced else []),
-        util.syspath(target),
+        syspath(target),
     ]
 
     try:
@@ -282,12 +288,12 @@ def im_convert_format(source, target, deinterlaced):
         return source
 
 
-def pil_convert_format(source, target, deinterlaced):
+def pil_convert_format(artresizer, 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)
+        with Image.open(syspath(source)) as im:
+            im.save(py3_path(target), progressive=not deinterlaced)
             return target
     except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError,
             OSError):
@@ -301,6 +307,83 @@ BACKEND_CONVERT_IMAGE_FORMAT = {
 }
 
 
+def im_compare(artresizer, im1, im2, compare_threshold):
+    is_windows = platform.system() == "Windows"
+
+    # Converting images to grayscale tends to minimize the weight
+    # of colors in the diff score. So we first convert both images
+    # to grayscale and then pipe them into the `compare` command.
+    # On Windows, ImageMagick doesn't support the magic \\?\ prefix
+    # on paths, so we pass `prefix=False` to `syspath`.
+    convert_cmd = artresizer.im_convert_cmd + [
+        syspath(im2, prefix=False), syspath(im1, prefix=False),
+        '-colorspace', 'gray', 'MIFF:-'
+    ]
+    compare_cmd = artresizer.im_compare_cmd + [
+        '-metric', 'PHASH', '-', 'null:',
+    ]
+    log.debug('comparing images with pipeline {} | {}',
+              convert_cmd, compare_cmd)
+    convert_proc = subprocess.Popen(
+        convert_cmd,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        close_fds=not is_windows,
+    )
+    compare_proc = subprocess.Popen(
+        compare_cmd,
+        stdin=convert_proc.stdout,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        close_fds=not is_windows,
+    )
+
+    # Check the convert output. We're not interested in the
+    # standard output; that gets piped to the next stage.
+    convert_proc.stdout.close()
+    convert_stderr = convert_proc.stderr.read()
+    convert_proc.stderr.close()
+    convert_proc.wait()
+    if convert_proc.returncode:
+        log.debug(
+            'ImageMagick convert failed with status {}: {!r}',
+            convert_proc.returncode,
+            convert_stderr,
+        )
+        return None
+
+    # Check the compare output.
+    stdout, stderr = compare_proc.communicate()
+    if compare_proc.returncode:
+        if compare_proc.returncode != 1:
+            log.debug('ImageMagick compare failed: {0}, {1}',
+                      displayable_path(im2), displayable_path(im1))
+            return None
+        out_str = stderr
+    else:
+        out_str = stdout
+
+    try:
+        phash_diff = float(out_str)
+    except ValueError:
+        log.debug('IM output is not a number: {0!r}', out_str)
+        return None
+
+    log.debug('ImageMagick compare score: {0}', phash_diff)
+    return phash_diff <= compare_threshold
+
+
+def pil_compare(artresizer, im1, im2, compare_threshold):
+    # It is an error to call this when ArtResizer.can_compare is not True.
+    raise NotImplementedError()
+
+
+BACKEND_COMPARE = {
+    PIL: pil_compare,
+    IMAGEMAGICK: im_compare,
+}
+
+
 class Shareable(type):
     """A pseudo-singleton metaclass that allows both shared and
     non-shared instances. The ``MyClass.shared`` property holds a
@@ -328,7 +411,6 @@ class ArtResizer(metaclass=Shareable):
         """
         self.method = self._check_method()
         log.debug("artresizer: method is {0}", self.method)
-        self.can_compare = self._can_compare()
 
         # Use ImageMagick's magick binary when it's available. If it's
         # not, fall back to the older, separate convert and identify
@@ -338,9 +420,11 @@ class ArtResizer(metaclass=Shareable):
             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(
         self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
@@ -352,7 +436,7 @@ class ArtResizer(metaclass=Shareable):
         """
         if self.local:
             func = BACKEND_FUNCS[self.method[0]]
-            return func(maxwidth, path_in, path_out,
+            return func(self, maxwidth, path_in, path_out,
                         quality=quality, max_filesize=max_filesize)
         else:
             return path_in
@@ -360,7 +444,7 @@ class ArtResizer(metaclass=Shareable):
     def deinterlace(self, path_in, path_out=None):
         if self.local:
             func = DEINTERLACE_FUNCS[self.method[0]]
-            return func(path_in, path_out)
+            return func(self, path_in, path_out)
         else:
             return path_in
 
@@ -389,7 +473,8 @@ class ArtResizer(metaclass=Shareable):
         """
         if self.local:
             func = BACKEND_GET_SIZE[self.method[0]]
-            return func(path_in)
+            return func(self, path_in)
+        return None
 
     def get_format(self, path_in):
         """Returns the format of the image as a string.
@@ -398,7 +483,8 @@ class ArtResizer(metaclass=Shareable):
         """
         if self.local:
             func = BACKEND_GET_FORMAT[self.method[0]]
-            return func(path_in)
+            return func(self, path_in)
+        return None
 
     def reformat(self, path_in, new_format, deinterlaced=True):
         """Converts image to desired format, updating its extension, but
@@ -423,17 +509,28 @@ class ArtResizer(metaclass=Shareable):
         # file path was removed
         result_path = path_in
         try:
-            result_path = func(path_in, path_new, deinterlaced)
+            result_path = func(self, path_in, path_new, deinterlaced)
         finally:
             if result_path != path_in:
                 os.unlink(path_in)
         return result_path
 
-    def _can_compare(self):
+    @property
+    def can_compare(self):
         """A boolean indicating whether image comparison is available"""
 
         return self.method[0] == IMAGEMAGICK and self.method[1] > (6, 8, 7)
 
+    def compare(self, im1, im2, compare_threshold):
+        """Return a boolean indicating whether two images are similar.
+
+        Only available locally.
+        """
+        if self.local:
+            func = BACKEND_COMPARE[self.method[0]]
+            return func(self, im1, im2, compare_threshold)
+        return None
+
     @staticmethod
     def _check_method():
         """Return a tuple indicating an available method and its version.
diff --git a/beetsplug/convert.py b/beetsplug/convert.py
index f384656f1..63bfea9b3 100644
--- a/beetsplug/convert.py
+++ b/beetsplug/convert.py
@@ -343,10 +343,9 @@ class ConvertPlugin(BeetsPlugin):
             if self.config['embed'] and not linked:
                 album = item._cached_album
                 if album and album.artpath:
-                    maxwidth = self._get_art_resize(album.artpath)
                     self._log.debug('embedding album art from {}',
                                     util.displayable_path(album.artpath))
-                    art.embed_item(self._log, item, album.artpath, maxwidth,
+                    art.embed_item(self._log, item, album.artpath,
                                    itempath=converted, id3v23=id3v23)
 
             if keep_new:
@@ -390,10 +389,20 @@ class ConvertPlugin(BeetsPlugin):
             return
 
         # Decide whether we need to resize the cover-art image.
-        maxwidth = self._get_art_resize(album.artpath)
+        resize = False
+        maxwidth = None
+        if self.config['album_art_maxwidth']:
+            maxwidth = self.config['album_art_maxwidth'].get(int)
+            size = ArtResizer.shared.get_size(album.artpath)
+            self._log.debug('image size: {}', size)
+            if size:
+                resize = size[0] > maxwidth
+            else:
+                self._log.warning('Could not get size of image (please see '
+                                  'documentation for dependencies).')
 
         # Either copy or resize (while copying) the image.
-        if maxwidth is not None:
+        if resize:
             self._log.info('Resizing cover art from {0} to {1}',
                            util.displayable_path(album.artpath),
                            util.displayable_path(dest))
@@ -522,24 +531,6 @@ class ConvertPlugin(BeetsPlugin):
                 )
                 util.remove(source_path, False)
 
-    def _get_art_resize(self, artpath):
-        """For a given piece of album art, determine whether or not it needs
-        to be resized according to the user's settings. If so, returns the
-        new size. If not, returns None.
-        """
-        newwidth = None
-        if self.config['album_art_maxwidth']:
-            maxwidth = self.config['album_art_maxwidth'].get(int)
-            size = ArtResizer.shared.get_size(artpath)
-            self._log.debug('image size: {}', size)
-            if size:
-                if size[0] > maxwidth:
-                    newwidth = maxwidth
-            else:
-                self._log.warning('Could not get size of image (please see '
-                                  'documentation for dependencies).')
-        return newwidth
-
     def _cleanup(self, task, session):
         for path in task.old_paths:
             if path in _temp_files:
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 2878c7953..bbe617638 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -15,8 +15,6 @@ New features:
 
 Bug fixes:
 
-* :doc:`/plugins/convert`: Resize album art when embedding
-  :bug:`2116`
 * :doc:`/plugins/deezer`: Fix auto tagger pagination issues (fetch beyond the
   first 25 tracks of a release).
 * :doc:`/plugins/spotify`: Fix auto tagger pagination issues (fetch beyond the
@@ -48,6 +46,9 @@ Bug fixes:
 * :doc:`plugins/replaygain`: Correctly handle the ``overwrite`` config option,
   which forces recomputing ReplayGain values on import even for tracks
   that already have the tags.
+* :doc:`plugins/embedart`: Fix a crash when using recent versions of
+  ImageMagick and the ``compare_threshold`` option.
+  :bug:`4272`
 
 For packagers:
 
diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst
index 799a20dbb..d53b8dc6d 100644
--- a/docs/plugins/convert.rst
+++ b/docs/plugins/convert.rst
@@ -72,8 +72,7 @@ file. The available options are:
   using the ``-a`` option. Default: ``no``.
 - **album_art_maxwidth**: Downscale album art if it's too big. The resize
   operation reduces image width to at most ``maxwidth`` pixels while
-  preserving the aspect ratio. The specified image size will apply to both
-  embedded album art and external image files.
+  preserving the aspect ratio.
 - **dest**: The directory where the files will be converted (or copied) to.
   Default: none.
 - **embed**: Embed album art in converted items. Default: ``yes``.


Test patch:
diff --git a/test/test_art_resize.py b/test/test_art_resize.py
index 73847e0a6..4600bab77 100644
--- a/test/test_art_resize.py
+++ b/test/test_art_resize.py
@@ -50,6 +50,7 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper):
         """Test resizing based on file size, given a resize_func."""
         # Check quality setting unaffected by new parameter
         im_95_qual = resize_func(
+            ArtResizer.shared,
             225,
             self.IMG_225x225,
             quality=95,
@@ -60,6 +61,7 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper):
 
         # Attempt a lower filesize with same quality
         im_a = resize_func(
+            ArtResizer.shared,
             225,
             self.IMG_225x225,
             quality=95,
@@ -72,6 +74,7 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper):
 
         # Attempt with lower initial quality
         im_75_qual = resize_func(
+            ArtResizer.shared,
             225,
             self.IMG_225x225,
             quality=75,
@@ -80,6 +83,7 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper):
         self.assertExists(im_75_qual)
 
         im_b = resize_func(
+            ArtResizer.shared,
             225,
             self.IMG_225x225,
             quality=95,
@@ -107,7 +111,7 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper):
         Check if pil_deinterlace function returns images
         that are non-progressive
         """
-        path = pil_deinterlace(self.IMG_225x225)
+        path = pil_deinterlace(ArtResizer.shared, self.IMG_225x225)
         from PIL import Image
         with Image.open(path) as img:
             self.assertFalse('progression' in img.info)
@@ -119,7 +123,7 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper):
         Check if im_deinterlace function returns images
         that are non-progressive.
         """
-        path = im_deinterlace(self.IMG_225x225)
+        path = im_deinterlace(ArtResizer.shared, self.IMG_225x225)
         cmd = ArtResizer.shared.im_identify_cmd + [
             '-format', '%[interlace]', syspath(path, prefix=False),
         ]
diff --git a/test/test_embedart.py b/test/test_embedart.py
index 6b6d61614..0fed08f98 100644
--- a/test/test_embedart.py
+++ b/test/test_embedart.py
@@ -24,7 +24,7 @@ from test.helper import TestHelper
 
 from mediafile import MediaFile
 from beets import config, logging, ui
-from beets.util import syspath, displayable_path
+from beets.util import artresizer, syspath, displayable_path
 from beets.util.artresizer import ArtResizer
 from beets import art
 
@@ -216,16 +216,31 @@ class EmbedartCliTest(_common.TestCase, TestHelper):
         self.assertEqual(mediafile.images[0].data, self.image_data)
 
 
-@patch('beets.art.subprocess')
+class DummyArtResizer(ArtResizer):
+    """An `ArtResizer` which pretends that ImageMagick is available, and has
+    a sufficiently recent version to support image comparison.
+    """
+    @staticmethod
+    def _check_method():
+        return artresizer.IMAGEMAGICK, (7, 0, 0), True
+
+
+@patch('beets.util.artresizer.subprocess')
 @patch('beets.art.extract')
 class ArtSimilarityTest(unittest.TestCase):
     def setUp(self):
         self.item = _common.item()
         self.log = logging.getLogger('beets.embedart')
+        self.artresizer = DummyArtResizer()
 
     def _similarity(self, threshold):
-        return art.check_art_similarity(self.log, self.item, b'path',
-                                        threshold)
+        return art.check_art_similarity(
+            self.log,
+            self.item,
+            b'path',
+            threshold,
+            artresizer=self.artresizer,
+        )
 
     def _popen(self, status=0, stdout="", stderr=""):
         """Create a mock `Popen` object."""