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:
Šarūnas Nejus 2024-07-02 02:00:15 +01:00
parent 56d9d9670f
commit 1fda7b6111
No known key found for this signature in database
GPG key ID: DD28F6704DBE3435
6 changed files with 90 additions and 26 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -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:

View file

@ -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()

View file

@ -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