mirror of
https://github.com/rembo10/headphones.git
synced 2026-01-14 17:28:01 -05:00
Mostly just updating libraries, removing string encoding/decoding, fixing some edge cases. No new functionality was added in this commit.
493 lines
15 KiB
Python
493 lines
15 KiB
Python
# This file is part of beets.
|
|
# Copyright 2016, Fabrice Laporte
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining
|
|
# a copy of this software and associated documentation files (the
|
|
# "Software"), to deal in the Software without restriction, including
|
|
# without limitation the rights to use, copy, modify, merge, publish,
|
|
# distribute, sublicense, and/or sell copies of the Software, and to
|
|
# permit persons to whom the Software is furnished to do so, subject to
|
|
# the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be
|
|
# included in all copies or substantial portions of the Software.
|
|
|
|
"""Abstraction layer to resize images using PIL, ImageMagick, or a
|
|
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
|
|
from beets import logging
|
|
from beets import util
|
|
|
|
# Resizing methods
|
|
PIL = 1
|
|
IMAGEMAGICK = 2
|
|
WEBPROXY = 3
|
|
|
|
PROXY_URL = 'https://images.weserv.nl/'
|
|
|
|
log = logging.getLogger('beets')
|
|
|
|
|
|
def resize_url(url, maxwidth, quality=0):
|
|
"""Return a proxied image URL that resizes the original image to
|
|
maxwidth (preserving aspect ratio).
|
|
"""
|
|
params = {
|
|
'url': url.replace('http://', ''),
|
|
'w': maxwidth,
|
|
}
|
|
|
|
if quality > 0:
|
|
params['q'] = quality
|
|
|
|
return '{}?{}'.format(PROXY_URL, urlencode(params))
|
|
|
|
|
|
def temp_file_for(path):
|
|
"""Return an unused filename with the same extension as the
|
|
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)
|
|
|
|
|
|
def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
|
|
"""Resize using Python Imaging Library (PIL). Return the output path
|
|
of resized image.
|
|
"""
|
|
path_out = path_out or temp_file_for(path_in)
|
|
from PIL import Image
|
|
|
|
log.debug('artresizer: PIL resizing {0} to {1}',
|
|
util.displayable_path(path_in), util.displayable_path(path_out))
|
|
|
|
try:
|
|
im = Image.open(util.syspath(path_in))
|
|
size = maxwidth, maxwidth
|
|
im.thumbnail(size, Image.ANTIALIAS)
|
|
|
|
if quality == 0:
|
|
# Use PIL's default quality.
|
|
quality = -1
|
|
|
|
# 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)
|
|
|
|
if max_filesize > 0:
|
|
# If maximum filesize is set, we attempt to lower the quality of
|
|
# jpeg conversion by a proportional amount, up to 3 attempts
|
|
# First, set the maximum quality to either provided, or 95
|
|
if quality > 0:
|
|
lower_qual = quality
|
|
else:
|
|
lower_qual = 95
|
|
for i in range(5):
|
|
# 5 attempts is an abitrary choice
|
|
filesize = os.stat(util.syspath(path_out)).st_size
|
|
log.debug("PIL Pass {0} : Output size: {1}B", i, filesize)
|
|
if filesize <= max_filesize:
|
|
return path_out
|
|
# The relationship between filesize & quality will be
|
|
# image dependent.
|
|
lower_qual -= 10
|
|
# Restrict quality dropping below 10
|
|
if lower_qual < 10:
|
|
lower_qual = 10
|
|
# Use optimize flag to improve filesize decrease
|
|
im.save(util.py3_path(path_out), quality=lower_qual,
|
|
optimize=True, progressive=False)
|
|
log.warning("PIL Failed to resize file to below {0}B",
|
|
max_filesize)
|
|
return path_out
|
|
|
|
else:
|
|
return path_out
|
|
except OSError:
|
|
log.error("PIL cannot create thumbnail for '{0}'",
|
|
util.displayable_path(path_in))
|
|
return path_in
|
|
|
|
|
|
def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
|
|
"""Resize using ImageMagick.
|
|
|
|
Use the ``magick`` program or ``convert`` on older versions. Return
|
|
the output path of resized image.
|
|
"""
|
|
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))
|
|
|
|
# "-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),
|
|
'-resize', f'{maxwidth}x>',
|
|
'-interlace', 'none',
|
|
]
|
|
|
|
if quality > 0:
|
|
cmd += ['-quality', f'{quality}']
|
|
|
|
# "-define jpeg:extent=SIZEb" sets the target filesize for imagemagick to
|
|
# SIZE in bytes.
|
|
if max_filesize > 0:
|
|
cmd += ['-define', f'jpeg:extent={max_filesize}b']
|
|
|
|
cmd.append(util.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))
|
|
return path_in
|
|
|
|
return path_out
|
|
|
|
|
|
BACKEND_FUNCS = {
|
|
PIL: pil_resize,
|
|
IMAGEMAGICK: im_resize,
|
|
}
|
|
|
|
|
|
def pil_getsize(path_in):
|
|
from PIL import Image
|
|
|
|
try:
|
|
im = Image.open(util.syspath(path_in))
|
|
return im.size
|
|
except OSError as exc:
|
|
log.error("PIL could not read file {}: {}",
|
|
util.displayable_path(path_in), exc)
|
|
|
|
|
|
def im_getsize(path_in):
|
|
cmd = ArtResizer.shared.im_identify_cmd + \
|
|
['-format', '%w %h', util.syspath(path_in, prefix=False)]
|
|
|
|
try:
|
|
out = util.command_output(cmd).stdout
|
|
except subprocess.CalledProcessError as exc:
|
|
log.warning('ImageMagick size query failed')
|
|
log.debug(
|
|
'`convert` exited with (status {}) when '
|
|
'getting size with command {}:\n{}',
|
|
exc.returncode, cmd, exc.output.strip()
|
|
)
|
|
return
|
|
try:
|
|
return tuple(map(int, out.split(b' ')))
|
|
except IndexError:
|
|
log.warning('Could not understand IM output: {0!r}', out)
|
|
|
|
|
|
BACKEND_GET_SIZE = {
|
|
PIL: pil_getsize,
|
|
IMAGEMAGICK: im_getsize,
|
|
}
|
|
|
|
|
|
def pil_deinterlace(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)
|
|
return path_out
|
|
except IOError:
|
|
return path_in
|
|
|
|
|
|
def im_deinterlace(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),
|
|
'-interlace', 'none',
|
|
util.syspath(path_out, prefix=False),
|
|
]
|
|
|
|
try:
|
|
util.command_output(cmd)
|
|
return path_out
|
|
except subprocess.CalledProcessError:
|
|
return path_in
|
|
|
|
|
|
DEINTERLACE_FUNCS = {
|
|
PIL: pil_deinterlace,
|
|
IMAGEMAGICK: im_deinterlace,
|
|
}
|
|
|
|
|
|
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
|
|
lazily-created shared instance of ``MyClass`` while calling
|
|
``MyClass()`` to construct a new object works as usual.
|
|
"""
|
|
|
|
def __init__(cls, name, bases, dict):
|
|
super().__init__(name, bases, dict)
|
|
cls._instance = None
|
|
|
|
@property
|
|
def shared(cls):
|
|
if cls._instance is None:
|
|
cls._instance = cls()
|
|
return cls._instance
|
|
|
|
|
|
class ArtResizer(metaclass=Shareable):
|
|
"""A singleton class that performs image resizes.
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Create a resizer object with an inferred method.
|
|
"""
|
|
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
|
|
# 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, quality=0, max_filesize=0
|
|
):
|
|
"""Manipulate an image file according to the method, returning a
|
|
new path. For PIL or IMAGEMAGIC methods, resizes the image to a
|
|
temporary file and encodes with the specified quality level.
|
|
For WEBPROXY, returns `path_in` unmodified.
|
|
"""
|
|
if self.local:
|
|
func = BACKEND_FUNCS[self.method[0]]
|
|
return func(maxwidth, path_in, path_out,
|
|
quality=quality, max_filesize=max_filesize)
|
|
else:
|
|
return path_in
|
|
|
|
def deinterlace(self, path_in, path_out=None):
|
|
if self.local:
|
|
func = DEINTERLACE_FUNCS[self.method[0]]
|
|
return func(path_in, path_out)
|
|
else:
|
|
return path_in
|
|
|
|
def proxy_url(self, maxwidth, url, quality=0):
|
|
"""Modifies an image URL according the method, returning a new
|
|
URL. For WEBPROXY, a URL on the proxy server is returned.
|
|
Otherwise, the URL is returned unmodified.
|
|
"""
|
|
if self.local:
|
|
return url
|
|
else:
|
|
return resize_url(url, maxwidth, quality)
|
|
|
|
@property
|
|
def local(self):
|
|
"""A boolean indicating whether the resizing method is performed
|
|
locally (i.e., PIL or ImageMagick).
|
|
"""
|
|
return self.method[0] in BACKEND_FUNCS
|
|
|
|
def get_size(self, path_in):
|
|
"""Return the size of an image file as an int couple (width, height)
|
|
in pixels.
|
|
|
|
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"""
|
|
|
|
return self.method[0] == IMAGEMAGICK and self.method[1] > (6, 8, 7)
|
|
|
|
@staticmethod
|
|
def _check_method():
|
|
"""Return a tuple indicating an available method and its version.
|
|
|
|
The result has at least two elements:
|
|
- The method, eitehr WEBPROXY, PIL, or IMAGEMAGICK.
|
|
- The version.
|
|
|
|
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:
|
|
out = util.command_output(cmd).stdout
|
|
except (subprocess.CalledProcessError, OSError) as exc:
|
|
log.debug('ImageMagick version check failed: {}', exc)
|
|
else:
|
|
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
|