mirror of
https://github.com/beetbox/beets.git
synced 2025-12-08 01:23:09 +01:00
fetchart, artresizer: Create art files in predictable directories
This allows to clean them up in art (1) fetching, (2) resizing and (3) deinterlace tests.
This commit is contained in:
parent
56d9d9670f
commit
1fda7b6111
6 changed files with 90 additions and 26 deletions
|
|
@ -29,6 +29,7 @@ information or mock the environment.
|
||||||
- The `TestHelper` class encapsulates various fixtures that can be set up.
|
- The `TestHelper` class encapsulates various fixtures that can be set up.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
|
|
@ -39,6 +40,7 @@ from contextlib import contextmanager
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from tempfile import mkdtemp, mkstemp
|
from tempfile import mkdtemp, mkstemp
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
import responses
|
import responses
|
||||||
from mediafile import Image, MediaFile
|
from mediafile import Image, MediaFile
|
||||||
|
|
@ -50,7 +52,12 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo
|
||||||
from beets.library import Album, Item, Library
|
from beets.library import Album, Item, Library
|
||||||
from beets.test import _common
|
from beets.test import _common
|
||||||
from beets.ui.commands import TerminalImportSession
|
from beets.ui.commands import TerminalImportSession
|
||||||
from beets.util import MoveOperation, bytestring_path, syspath
|
from beets.util import (
|
||||||
|
MoveOperation,
|
||||||
|
bytestring_path,
|
||||||
|
clean_module_tempdir,
|
||||||
|
syspath,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LogCapture(logging.Handler):
|
class LogCapture(logging.Handler):
|
||||||
|
|
@ -952,3 +959,13 @@ class FetchImageHelper:
|
||||||
# imghdr reads 32 bytes
|
# imghdr reads 32 bytes
|
||||||
body=self.IMAGEHEADER.get(file_type, b"").ljust(32, b"\x00"),
|
body=self.IMAGEHEADER.get(file_type, b"").ljust(32, b"\x00"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CleanupModulesMixin:
|
||||||
|
modules: ClassVar[tuple[str, ...]]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls) -> None:
|
||||||
|
"""Remove files created by the plugin."""
|
||||||
|
for module in cls.modules:
|
||||||
|
clean_module_tempdir(module)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
# included in all copies or substantial portions of the Software.
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
"""Miscellaneous utility functions."""
|
"""Miscellaneous utility functions."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import errno
|
import errno
|
||||||
import fnmatch
|
import fnmatch
|
||||||
|
|
@ -26,9 +27,11 @@ import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import traceback
|
import traceback
|
||||||
from collections import Counter, namedtuple
|
from collections import Counter, namedtuple
|
||||||
|
from contextlib import suppress
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from logging import Logger
|
from logging import Logger
|
||||||
from multiprocessing.pool import ThreadPool
|
from multiprocessing.pool import ThreadPool
|
||||||
|
from pathlib import Path
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
AnyStr,
|
AnyStr,
|
||||||
|
|
@ -58,6 +61,7 @@ MAX_FILENAME_LENGTH = 200
|
||||||
WINDOWS_MAGIC_PREFIX = "\\\\?\\"
|
WINDOWS_MAGIC_PREFIX = "\\\\?\\"
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
Bytes_or_String: TypeAlias = Union[str, bytes]
|
Bytes_or_String: TypeAlias = Union[str, bytes]
|
||||||
|
PathLike = Union[str, bytes, Path]
|
||||||
|
|
||||||
|
|
||||||
class HumanReadableException(Exception):
|
class HumanReadableException(Exception):
|
||||||
|
|
@ -1076,3 +1080,46 @@ class cached_classproperty: # noqa: N801
|
||||||
self.cache[owner] = self.getter(owner)
|
self.cache[owner] = self.getter(owner)
|
||||||
|
|
||||||
return self.cache[owner]
|
return self.cache[owner]
|
||||||
|
|
||||||
|
|
||||||
|
def get_module_tempdir(module: str) -> Path:
|
||||||
|
"""Return the temporary directory for the given module.
|
||||||
|
|
||||||
|
The directory is created within the `/tmp/beets/<module>` directory on
|
||||||
|
Linux (or the equivalent temporary directory on other systems).
|
||||||
|
|
||||||
|
Dots in the module name are replaced by underscores.
|
||||||
|
"""
|
||||||
|
module = module.replace("beets.", "").replace(".", "_")
|
||||||
|
return Path(tempfile.gettempdir()) / "beets" / module
|
||||||
|
|
||||||
|
|
||||||
|
def clean_module_tempdir(module: str) -> None:
|
||||||
|
"""Clean the temporary directory for the given module."""
|
||||||
|
tempdir = get_module_tempdir(module)
|
||||||
|
shutil.rmtree(tempdir, ignore_errors=True)
|
||||||
|
with suppress(OSError):
|
||||||
|
# remove parent (/tmp/beets) directory if it is empty
|
||||||
|
tempdir.parent.rmdir()
|
||||||
|
|
||||||
|
|
||||||
|
def get_temp_filename(
|
||||||
|
module: str,
|
||||||
|
prefix: str = "",
|
||||||
|
path: PathLike | None = None,
|
||||||
|
suffix: str = "",
|
||||||
|
) -> bytes:
|
||||||
|
"""Return temporary filename for the given module and prefix.
|
||||||
|
|
||||||
|
The filename starts with the given `prefix`.
|
||||||
|
If 'suffix' is given, it is used a the file extension.
|
||||||
|
If 'path' is given, we use the same suffix.
|
||||||
|
"""
|
||||||
|
if not suffix and path:
|
||||||
|
suffix = Path(os.fsdecode(path)).suffix
|
||||||
|
|
||||||
|
tempdir = get_module_tempdir(module)
|
||||||
|
tempdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
_, filename = tempfile.mkstemp(dir=tempdir, prefix=prefix, suffix=suffix)
|
||||||
|
return bytestring_path(filename)
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,10 @@ import platform
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from tempfile import NamedTemporaryFile
|
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from beets import logging, util
|
from beets import logging, util
|
||||||
from beets.util import bytestring_path, displayable_path, syspath
|
from beets.util import displayable_path, get_temp_filename, syspath
|
||||||
|
|
||||||
PROXY_URL = "https://images.weserv.nl/"
|
PROXY_URL = "https://images.weserv.nl/"
|
||||||
|
|
||||||
|
|
@ -48,15 +47,6 @@ def resize_url(url, maxwidth, quality=0):
|
||||||
return "{}?{}".format(PROXY_URL, urlencode(params))
|
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=os.fsdecode(ext), delete=False) as f:
|
|
||||||
return bytestring_path(f.name)
|
|
||||||
|
|
||||||
|
|
||||||
class LocalBackendNotAvailableError(Exception):
|
class LocalBackendNotAvailableError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -141,7 +131,9 @@ class IMBackend(LocalBackend):
|
||||||
Use the ``magick`` program or ``convert`` on older versions. Return
|
Use the ``magick`` program or ``convert`` on older versions. Return
|
||||||
the output path of resized image.
|
the output path of resized image.
|
||||||
"""
|
"""
|
||||||
path_out = path_out or temp_file_for(path_in)
|
if not path_out:
|
||||||
|
path_out = get_temp_filename(__name__, "resize_IM_", path_in)
|
||||||
|
|
||||||
log.debug(
|
log.debug(
|
||||||
"artresizer: ImageMagick resizing {0} to {1}",
|
"artresizer: ImageMagick resizing {0} to {1}",
|
||||||
displayable_path(path_in),
|
displayable_path(path_in),
|
||||||
|
|
@ -208,7 +200,8 @@ class IMBackend(LocalBackend):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def deinterlace(self, path_in, path_out=None):
|
def deinterlace(self, path_in, path_out=None):
|
||||||
path_out = path_out or temp_file_for(path_in)
|
if not path_out:
|
||||||
|
path_out = get_temp_filename(__name__, "deinterlace_IM_", path_in)
|
||||||
|
|
||||||
cmd = self.convert_cmd + [
|
cmd = self.convert_cmd + [
|
||||||
syspath(path_in, prefix=False),
|
syspath(path_in, prefix=False),
|
||||||
|
|
@ -366,7 +359,9 @@ class PILBackend(LocalBackend):
|
||||||
"""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.
|
||||||
"""
|
"""
|
||||||
path_out = path_out or temp_file_for(path_in)
|
if not path_out:
|
||||||
|
path_out = get_temp_filename(__name__, "resize_PIL_", path_in)
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
log.debug(
|
log.debug(
|
||||||
|
|
@ -442,7 +437,9 @@ class PILBackend(LocalBackend):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def deinterlace(self, path_in, path_out=None):
|
def deinterlace(self, path_in, path_out=None):
|
||||||
path_out = path_out or temp_file_for(path_in)
|
if not path_out:
|
||||||
|
path_out = get_temp_filename(__name__, "deinterlace_PIL_", path_in)
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,13 @@ import os
|
||||||
import re
|
import re
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from tempfile import NamedTemporaryFile
|
|
||||||
|
|
||||||
import confuse
|
import confuse
|
||||||
import requests
|
import requests
|
||||||
from mediafile import image_mime_type
|
from mediafile import image_mime_type
|
||||||
|
|
||||||
from beets import config, importer, plugins, ui, util
|
from beets import config, importer, plugins, ui, util
|
||||||
from beets.util import bytestring_path, sorted_walk, syspath
|
from beets.util import bytestring_path, get_temp_filename, sorted_walk, syspath
|
||||||
from beets.util.artresizer import ArtResizer
|
from beets.util.artresizer import ArtResizer
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -412,17 +411,17 @@ class RemoteArtSource(ArtSource):
|
||||||
ext,
|
ext,
|
||||||
)
|
)
|
||||||
|
|
||||||
suffix = os.fsdecode(ext)
|
filename = get_temp_filename(__name__, suffix=ext.decode())
|
||||||
with NamedTemporaryFile(suffix=suffix, delete=False) as fh:
|
with open(filename, "wb") as fh:
|
||||||
# write the first already loaded part of the image
|
# write the first already loaded part of the image
|
||||||
fh.write(header)
|
fh.write(header)
|
||||||
# download the remaining part of the image
|
# download the remaining part of the image
|
||||||
for chunk in data:
|
for chunk in data:
|
||||||
fh.write(chunk)
|
fh.write(chunk)
|
||||||
self._log.debug(
|
self._log.debug(
|
||||||
"downloaded art to: {0}", util.displayable_path(fh.name)
|
"downloaded art to: {0}", util.displayable_path(filename)
|
||||||
)
|
)
|
||||||
candidate.path = util.bytestring_path(fh.name)
|
candidate.path = util.bytestring_path(filename)
|
||||||
return
|
return
|
||||||
|
|
||||||
except (OSError, requests.RequestException, TypeError) as exc:
|
except (OSError, requests.RequestException, TypeError) as exc:
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import responses
|
||||||
from beets import config, importer, library, logging, util
|
from beets import config, importer, library, logging, util
|
||||||
from beets.autotag import AlbumInfo, AlbumMatch
|
from beets.autotag import AlbumInfo, AlbumMatch
|
||||||
from beets.test import _common
|
from beets.test import _common
|
||||||
from beets.test.helper import FetchImageHelper, capture_log
|
from beets.test.helper import CleanupModulesMixin, FetchImageHelper, capture_log
|
||||||
from beets.util import syspath
|
from beets.util import syspath
|
||||||
from beets.util.artresizer import ArtResizer
|
from beets.util.artresizer import ArtResizer
|
||||||
from beetsplug import fetchart
|
from beetsplug import fetchart
|
||||||
|
|
@ -44,7 +44,9 @@ class Settings:
|
||||||
setattr(self, k, v)
|
setattr(self, k, v)
|
||||||
|
|
||||||
|
|
||||||
class UseThePlugin(_common.TestCase):
|
class UseThePlugin(CleanupModulesMixin, _common.TestCase):
|
||||||
|
modules = (fetchart.__name__, ArtResizer.__module__)
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.plugin = fetchart.FetchArtPlugin()
|
self.plugin = fetchart.FetchArtPlugin()
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from beets.test import _common
|
from beets.test import _common
|
||||||
from beets.test.helper import TestHelper
|
from beets.test.helper import CleanupModulesMixin, TestHelper
|
||||||
from beets.util import command_output, syspath
|
from beets.util import command_output, syspath
|
||||||
from beets.util.artresizer import IMBackend, PILBackend
|
from beets.util.artresizer import IMBackend, PILBackend
|
||||||
|
|
||||||
|
|
@ -48,9 +48,11 @@ class DummyPILBackend(PILBackend):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ArtResizerFileSizeTest(_common.TestCase, TestHelper):
|
class ArtResizerFileSizeTest(CleanupModulesMixin, _common.TestCase, TestHelper):
|
||||||
"""Unittest test case for Art Resizer to a specific filesize."""
|
"""Unittest test case for Art Resizer to a specific filesize."""
|
||||||
|
|
||||||
|
modules = (IMBackend.__module__,)
|
||||||
|
|
||||||
IMG_225x225 = os.path.join(_common.RSRC, b"abbey.jpg")
|
IMG_225x225 = os.path.join(_common.RSRC, b"abbey.jpg")
|
||||||
IMG_225x225_SIZE = os.stat(syspath(IMG_225x225)).st_size
|
IMG_225x225_SIZE = os.stat(syspath(IMG_225x225)).st_size
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue