Repository: beetbox/beets

Issue:
Use explicit paths to ImageMagick tools on Windows
### ImageMagick/artresizer fails on windows?

```
PS C:\Users\Kai\Desktop> beet -vv import '.\Space Probe Taurus - 15.rar'
…
fetchart: trying source coverart for album Space Probe Taurus - Mondo Satan
ImageMagick check `convert --version` failed: Command 'convert --version' returned non-zero exit status 4
artresizer: method is (3, 0)
…
```

```
PS C:\Users\Kai> convert --version
Version: ImageMagick 7.0.2-1 Q16 x64 2016-06-23 http://www.imagemagick.org
Copyright: Copyright (C) 1999-2015 ImageMagick Studio LLC
License: http://www.imagemagick.org/script/license.php
Visual C++: 180040629
Features: Cipher DPC Modules OpenMP
Delegates (built-in): bzlib cairo flif freetype jng jp2 jpeg lcms lqr openexr pangocairo png ps rsvg tiff webp xml zlib
```
### Setup
- OS: Windows 10
- Python version: `Python 2.7.11 (v2.7.11:6d1b6a68f775, Dec  5 2015, 20:32:19) [MSC v.1500 32 bit (Intel)] on win32`
- beets version: 1.3.19
- ~~Turning off plugins made problem go away (yes/no):~~

My configuration (output of `beet config`) is:

``` yaml
PS C:\Users\Kai\Desktop> beet config
fetchart: The `fetch_art.remote_priority` configuration option has been deprecated, see the documentation.
lyrics:
    bing_lang_from: []
    auto: yes
    force: no
    bing_client_secret: REDACTED
    google_API_key: REDACTED
    google_engine_ID: REDACTED
    sources:
    - google
    - lyricwiki
    - lyrics.com
    - musixmatch
    fallback:
    genius_api_key: REDACTED
    bing_lang_to:

paths:
    default: $albumartist/$album%aunique{}/%if{$multidisc,CD$disc/}$albumartist - $album%aunique{} - $track $title
item_fields:
    multidisc: 1 if disctotal > 1 else 0
per_disc_numbering: yes
fetchart:
    auto: yes
    minwidth: 750
    maxwidth: 2000
    enforce_ratio: yes
    remote_priority: yes
    sources: coverart itunes amazon albumart filesystem
    google_engine: 001442825323518660753:hrh5ch1gjzm
    cautious: no
    store_source: no
    google_key: REDACTED
    fanarttv_key: REDACTED
    cover_names:
    - cover
    - front
    - art
    - album
    - folder
format_album: 'Musik: $albumartist - $album ($genre - $year) @ $bitrate kBit/s'
library: E:\beets\musiclibrary.db

replace:
    '[\\/]': _
    ^\.: ''
    '[\x00-\x1f]': _
    '[<>:"\?\*\|]': ''
    \.$: _
    \s+$: ''
    ^\s+: ''
ftintitle:
    auto: yes
    drop: no
    format: feat. {0}
zero:
    fields: comments
    update_database: no
    keep_fields: []
embedart:
    auto: yes
    remove_art_file: yes
    compare_threshold: 0
    ifempty: no
    maxwidth: 0

plugins: fetchart lastgenre embedart zero inline lyrics ftintitle discogs fromfilename edit
directory: F:\Audio\Mediathek\Musik

import:
    copy: yes
    write: yes
    resume: ask
    quiet_fallback: skip
    timid: no
    log: beetslog.txt
album_fields:
    bitrate: "total = 0\nfor item in items:\n    total += item.bitrate\nbitrate = total / len(items)\nreturn str(bitrate
)[:3]\n"
discogs:
    tokenfile: discogs_token.json
    apikey: REDACTED
    apisecret: REDACTED
    source_weight: 0.5
edit:
    itemfields: track title artist album
    albumfields: album albumartist
    ignore_fields: id path
lastgenre:
    count: 1
    source: album
    force: yes
    min_weight: 10
    auto: yes
    whitelist: yes
    separator: ', '
    fallback:
    canonical: no
pathfields: {}
```



Discussion:
Do you think you could provide the return code from `convert --version` in PowerShell? Run this after `convert --version` to get the exit code:

``` PowerShell
Write-Host "Code: $LASTEXITCODE"
```

Sure! :)

```
PS C:\Users\Kai> convert --version
Version: ImageMagick 7.0.2-1 Q16 x64 2016-06-23 http://www.imagemagick.org
Copyright: Copyright (C) 1999-2015 ImageMagick Studio LLC
License: http://www.imagemagick.org/script/license.php
Visual C++: 180040629
Features: Cipher DPC Modules OpenMP
Delegates (built-in): bzlib cairo flif freetype jng jp2 jpeg lcms lqr openexr pangocairo png ps rsvg tiff webp xml zlib
PS C:\Users\Kai> Write-Host "Code: $LASTEXITCODE"
Code: 0
```

I ran into this while experimenting with ImageMagick on Windows recently. The problem is that Windows has a `convert.exe` that shadows IM's `convert.exe` program. There's more background in this thread: https://github.com/beetbox/beets/issues/670#issuecomment-225279659

And there's also a relevant SO answer: http://stackoverflow.com/questions/15016974/running-imagemagick-convert-console-application-from-python

Alas, the behavior from inside beets doesn't necessarily match the behavior when typing `convert` in PowerShell. I wasn't able to find a way to make this work out of the box, so I think the only solution is to add a configuration option to beets to let you specify full paths to the IM tools. Unless anyone else has a better idea?

What I didnt get from reading the thread is: What has changed to break this? Since it seemed to work in the past ([1.3.16](https://github.com/beetbox/beets/issues/1721#issuecomment-167699576), .17 probably too)?

Is there any chance it was failing silently before? In previous versions, we could be erroneously convinced that we had a working `convert` and we'd just go ahead and try to use it -- which would fail every time.

There is a chance - how can we be sure? Place a large art file locally and force resizing using <1.3.18?

That seems logical -- but I'm reasonably confident that's what's going on. I can't see any other way around this aside from using a full, explicit path to the `convert` executable.

@sampsyo: I don't know how I forgot about my replies in #670 😆. Using absolute paths does seem to be the best way 😕. I still don't understand why `cmd.exe` prefers the built in `convert.exe` (when using `shell=True`) instead of the one provided by ImageMagick? Is it not `%PATH%` for some reason?

:smiley: Good point! You were the one who originally pointed this out.

I think the deal is that using `shell=False`, which we do for sanity reasons, doesn't use the Windows `%PATH%` at all. I admittedly don't understand this very well, but I think this SO answer indicates that you only get executables in the current working directory and builtins: http://stackoverflow.com/a/5659249

On the other hand, while searching for details, I found this _other_ SO answer that seems to indicate that the problem is just that `%PATH%` doesn't get inherited by the invocation by default. Explicitly threading it through might work: http://stackoverflow.com/a/14679560

That report seems a little unreliable, but perhaps worth a try?


Resolving patch:
diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py
--- a/beets/util/artresizer.py
+++ b/beets/util/artresizer.py
@@ -81,7 +81,8 @@ def pil_resize(maxwidth, path_in, path_out=None):
 
 
 def im_resize(maxwidth, path_in, path_out=None):
-    """Resize using ImageMagick's ``convert`` tool.
+    """Resize using ImageMagick's ``magick`` tool
+    (or fall back to ``convert`` for older versions).
     Return the output path of resized image.
     """
     path_out = path_out or temp_file_for(path_in)
@@ -91,16 +92,18 @@ def im_resize(maxwidth, path_in, path_out=None):
     # "-resize WIDTHx>" shrinks images with the width larger
     # than the given width while maintaining the aspect ratio
     # with regards to the height.
-    try:
-        util.command_output([
-            'convert', util.syspath(path_in, prefix=False),
+    cmd = ArtResizer.shared.im_convert_cmd + \
+        [util.syspath(path_in, prefix=False),
             '-resize', '{0}x>'.format(maxwidth),
-            util.syspath(path_out, prefix=False),
-        ])
+            util.syspath(path_out, prefix=False)]
+
+    try:
+        util.command_output(cmd)
     except subprocess.CalledProcessError:
         log.warning(u'artresizer: IM convert failed for {0}',
                     util.displayable_path(path_in))
         return path_in
+
     return path_out
 
 
@@ -121,8 +124,9 @@ def pil_getsize(path_in):
 
 
 def im_getsize(path_in):
-    cmd = ['identify', '-format', '%w %h',
-           util.syspath(path_in, prefix=False)]
+    cmd = ArtResizer.shared.im_identify_cmd + \
+        ['-format', '%w %h', util.syspath(path_in, prefix=False)]
+
     try:
         out = util.command_output(cmd)
     except subprocess.CalledProcessError as exc:
@@ -173,6 +177,18 @@ def __init__(self):
         log.debug(u"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
+        # 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']
+            else:
+                self.im_convert_cmd = ['magick']
+                self.im_identify_cmd = ['magick', 'identify']
+
     def resize(self, maxwidth, path_in, path_out=None):
         """Manipulate an image file according to the method, returning a
         new path. For PIL or IMAGEMAGIC methods, resizes the image to a
@@ -218,10 +234,14 @@ def _can_compare(self):
 
     @staticmethod
     def _check_method():
-        """Return a tuple indicating an available method and its version."""
+        """Return a tuple indicating an available method and its version.
+        If the method is ImageMagick, also return a bool indicating whether to
+        use the `magick` binary or legacy utils (`convert`, `identify`, etc.)
+        """
         version = get_im_version()
         if version:
-            return IMAGEMAGICK, version
+            version, legacy = version
+            return IMAGEMAGICK, version, legacy
 
         version = get_pil_version()
         if version:
@@ -231,29 +251,36 @@ def _check_method():
 
 
 def get_im_version():
-    """Return Image Magick version or None if it is unavailable
-    Try invoking ImageMagick's "convert".
+    """Return ImageMagick version/legacy-flag pair or None if the check fails.
+
+    Try invoking ImageMagick's `magick` binary first, then `convert` if
+    `magick` is unavailable.
     """
-    try:
-        out = util.command_output(['convert', '--version'])
-
-        if b'imagemagick' in out.lower():
-            pattern = br".+ (\d+)\.(\d+)\.(\d+).*"
-            match = re.search(pattern, out)
-            if match:
-                return (int(match.group(1)),
-                        int(match.group(2)),
-                        int(match.group(3)))
-            return (0,)
-
-    except (subprocess.CalledProcessError, OSError) as exc:
-        log.debug(u'ImageMagick check `convert --version` failed: {}', exc)
+    for cmd_name, legacy in ((['magick'], False), (['convert'], True)):
+        cmd = cmd_name + ['--version']
+
+        try:
+            out = util.command_output(cmd)
+
+            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
+
+        except (subprocess.CalledProcessError, OSError) as exc:
+            log.debug(u'ImageMagick version check failed: {}', exc)
+
         return None
 
 
 def get_pil_version():
-    """Return Image Magick version or None if it is unavailable
-    Try importing PIL."""
+    """Return Pillow version or None if it is unavailable
+    Try importing PIL.
+    """
     try:
         __import__('PIL', fromlist=[str('Image')])
         return (0,)


Test patch: