mirror of
https://github.com/beetbox/beets.git
synced 2026-07-18 05:57:02 -04:00
`requests_mock` was configured with a fallback matcher to return an exception on any request. The intent was to have tests fail if any requests are attempted in tests without matching mocks. This is the default behavior of `requests_mock`, so no fallback matcher is necessary. The side-effect of the fallback matcher, removed by this commit, was that 1) the `NoMockAddress` exception was raised to the function under test rather than to the test harness and 2) the `NoMockAddress` exception was not able to be constructed because the `exc` parameter to `requests_mock.register_uri` does not support exceptions that require arguments. This behavior is visible in the following log snippet, taken from an incorrectly-passing test: ``` DEBUG beets.fetchart:logging.py:163 downloading image: https://images.amazon.com/images/P/xxxx.01.LZZZZZZZ.jpg DEBUG requests_mock.adapter:adapter.py:256 GET https://images.amazon.com/images/P/xxxx.01.LZZZZZZZ.jpg 200 DEBUG beets.fetchart:logging.py:163 not a supported image: text/html DEBUG beets.fetchart:logging.py:163 downloading image: https://images.amazon.com/images/P/xxxx.02.LZZZZZZZ.jpg DEBUG beets.fetchart:logging.py:163 error fetching art: NoMockAddress.__init__() missing 1 required positional argument: 'request' ``` The test that emitted these logs passed because the `FetchArt` plugin is written to handle exceptions raised by art sources, skipping and logging an error. For the exception to cause a failing test, the default behavior of `requests_mock` of raising an exception to the test harness when no matching mock requests can be found must be allowed. Signed-off-by: Ross Williams <ross@ross-williams.net>
1135 lines
40 KiB
Python
1135 lines
40 KiB
Python
"""Tests for the album art fetchers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import patch
|
|
|
|
import confuse
|
|
import pytest
|
|
|
|
from beets import config, importer, logging, util
|
|
from beets.autotag import AlbumInfo, AlbumMatch, Distance
|
|
from beets.library import Album
|
|
from beets.test import _common
|
|
from beets.test.helper import (
|
|
RUNNING_IN_CI,
|
|
FetchImageHelper,
|
|
TestHelper,
|
|
has_program,
|
|
is_importable,
|
|
)
|
|
from beets.util import clean_module_tempdir, syspath
|
|
from beets.util.artresizer import ArtResizer
|
|
from beetsplug import fetchart
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator, Sequence
|
|
from unittest.mock import MagicMock
|
|
|
|
logger = logging.getLogger("beets.test_art")
|
|
|
|
REQUIRES_ARTRESIZER = pytest.mark.skipif(
|
|
not (has_program("magick") or is_importable("PIL")) and not RUNNING_IN_CI,
|
|
reason="requires ImageMagick or Pillow",
|
|
)
|
|
|
|
|
|
class Settings(fetchart.FetchArtPlugin):
|
|
"""Used to pass settings to the ArtSources when the plugin isn't fully
|
|
instantiated.
|
|
"""
|
|
|
|
def __init__(self, **kwargs):
|
|
for k, v in kwargs.items():
|
|
setattr(self, k, v)
|
|
|
|
|
|
class DummyRemoteArtSource(fetchart.RemoteArtSource):
|
|
NAME = "Dummy Art Source"
|
|
ID = "dummy"
|
|
|
|
def get(
|
|
self,
|
|
album,
|
|
plugin: fetchart.FetchArtPlugin,
|
|
paths: None | Sequence[bytes],
|
|
) -> Iterator[fetchart.Candidate]:
|
|
return iter(())
|
|
|
|
|
|
class UseThePlugin(TestHelper):
|
|
modules = (fetchart.__name__, ArtResizer.__module__)
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_plugin(self, setup):
|
|
self.plugin = fetchart.FetchArtPlugin()
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def requests_mock_all(self, requests_mock):
|
|
"""Will disable all outgoing requests and raise an error!
|
|
|
|
Tests need to mock all requests!
|
|
"""
|
|
# Register some “safe” mocks you actually want to allow, if any:
|
|
# requests_mock.get("https://example.com/health", json={"status": "ok"})
|
|
|
|
# Disable any URL not explicitly mocked
|
|
return
|
|
|
|
@pytest.fixture(autouse=True, scope="class")
|
|
def cleanup(self):
|
|
try:
|
|
yield
|
|
finally:
|
|
for module in self.modules:
|
|
clean_module_tempdir(module)
|
|
|
|
|
|
class CAAData:
|
|
"""Helper mixin for mocking requests to the Cover Art Archive."""
|
|
|
|
MBID_RELASE = "rid"
|
|
MBID_GROUP = "rgid"
|
|
|
|
RELEASE_URL = f"coverartarchive.org/release/{MBID_RELASE}"
|
|
GROUP_URL = f"coverartarchive.org/release-group/{MBID_GROUP}"
|
|
|
|
RELEASE_URL = f"https://{RELEASE_URL}"
|
|
GROUP_URL = f"https://{GROUP_URL}"
|
|
|
|
RESPONSE_RELEASE = """{
|
|
"images": [
|
|
{
|
|
"approved": false,
|
|
"back": false,
|
|
"comment": "GIF",
|
|
"edit": 12345,
|
|
"front": true,
|
|
"id": 12345,
|
|
"image": "http://coverartarchive.org/release/rid/12345.gif",
|
|
"thumbnails": {
|
|
"1200": "http://coverartarchive.org/release/rid/12345-1200.jpg",
|
|
"250": "http://coverartarchive.org/release/rid/12345-250.jpg",
|
|
"500": "http://coverartarchive.org/release/rid/12345-500.jpg",
|
|
"large": "http://coverartarchive.org/release/rid/12345-500.jpg",
|
|
"small": "http://coverartarchive.org/release/rid/12345-250.jpg"
|
|
},
|
|
"types": ["Front"]
|
|
},
|
|
{
|
|
"approved": false,
|
|
"back": false,
|
|
"comment": "",
|
|
"edit": 12345,
|
|
"front": false,
|
|
"id": 12345,
|
|
"image": "http://coverartarchive.org/release/rid/12345.jpg",
|
|
"thumbnails": {
|
|
"1200": "http://coverartarchive.org/release/rid/12345-1200.jpg",
|
|
"250": "http://coverartarchive.org/release/rid/12345-250.jpg",
|
|
"500": "http://coverartarchive.org/release/rid/12345-500.jpg",
|
|
"large": "http://coverartarchive.org/release/rid/12345-500.jpg",
|
|
"small": "http://coverartarchive.org/release/rid/12345-250.jpg"
|
|
},
|
|
"types": ["Front"]
|
|
}
|
|
],
|
|
"release": "https://musicbrainz.org/release/releaseid"
|
|
}"""
|
|
RESPONSE_RELEASE_WITHOUT_THUMBNAILS = """{
|
|
"images": [
|
|
{
|
|
"approved": false,
|
|
"back": false,
|
|
"comment": "GIF",
|
|
"edit": 12345,
|
|
"front": true,
|
|
"id": 12345,
|
|
"image": "http://coverartarchive.org/release/rid/12345.gif",
|
|
"types": ["Front"]
|
|
},
|
|
{
|
|
"approved": false,
|
|
"back": false,
|
|
"comment": "",
|
|
"edit": 12345,
|
|
"front": false,
|
|
"id": 12345,
|
|
"image": "http://coverartarchive.org/release/rid/12345.jpg",
|
|
"thumbnails": {
|
|
"large": "http://coverartarchive.org/release/rgid/12345-500.jpg",
|
|
"small": "http://coverartarchive.org/release/rgid/12345-250.jpg"
|
|
},
|
|
"types": ["Front"]
|
|
}
|
|
],
|
|
"release": "https://musicbrainz.org/release/releaseid"
|
|
}"""
|
|
RESPONSE_GROUP = """{
|
|
"images": [
|
|
{
|
|
"approved": false,
|
|
"back": false,
|
|
"comment": "",
|
|
"edit": 12345,
|
|
"front": true,
|
|
"id": 12345,
|
|
"image": "http://coverartarchive.org/release/releaseid/12345.jpg",
|
|
"thumbnails": {
|
|
"1200": "http://coverartarchive.org/release/rgid/12345-1200.jpg",
|
|
"250": "http://coverartarchive.org/release/rgid/12345-250.jpg",
|
|
"500": "http://coverartarchive.org/release/rgid/12345-500.jpg",
|
|
"large": "http://coverartarchive.org/release/rgid/12345-500.jpg",
|
|
"small": "http://coverartarchive.org/release/rgid/12345-250.jpg"
|
|
},
|
|
"types": ["Front"]
|
|
}
|
|
],
|
|
"release": "https://musicbrainz.org/release/release-id"
|
|
}"""
|
|
RESPONSE_GROUP_WITHOUT_THUMBNAILS = """{
|
|
"images": [
|
|
{
|
|
"approved": false,
|
|
"back": false,
|
|
"comment": "",
|
|
"edit": 12345,
|
|
"front": true,
|
|
"id": 12345,
|
|
"image": "http://coverartarchive.org/release/releaseid/12345.jpg",
|
|
"types": ["Front"]
|
|
}
|
|
],
|
|
"release": "https://musicbrainz.org/release/release-id"
|
|
}"""
|
|
|
|
|
|
class TestFetchImage(UseThePlugin, FetchImageHelper):
|
|
URL = "http://example.com/test.jpg"
|
|
|
|
@pytest.fixture
|
|
def settings(self) -> Settings:
|
|
return Settings(maxwidth=0)
|
|
|
|
@pytest.fixture
|
|
def source(self) -> DummyRemoteArtSource:
|
|
return DummyRemoteArtSource(logger, self.plugin.config)
|
|
|
|
@pytest.fixture
|
|
def candidate(self, source: DummyRemoteArtSource) -> fetchart.Candidate:
|
|
return fetchart.Candidate(logger, source.ID, url=self.URL)
|
|
|
|
def test_invalid_type_returns_none(
|
|
self, source, candidate, settings, image_request_mock
|
|
) -> None:
|
|
image_request_mock.get(self.URL, content_type="image/watercolour")
|
|
source.fetch_image(candidate, settings)
|
|
assert candidate.path is None
|
|
|
|
def test_jpeg_type_returns_path(
|
|
self, source, candidate, settings, image_request_mock
|
|
) -> None:
|
|
image_request_mock.get(self.URL, content_type="image/jpeg")
|
|
source.fetch_image(candidate, settings)
|
|
assert candidate.path is not None
|
|
|
|
def test_extension_set_by_content_type(
|
|
self, source, candidate, settings, image_request_mock
|
|
) -> None:
|
|
image_request_mock.get(self.URL, content_type="image/png")
|
|
source.fetch_image(candidate, settings)
|
|
assert candidate.path is not None
|
|
assert os.path.splitext(candidate.path)[1] == b".png"
|
|
assert Path(os.fsdecode(candidate.path)).exists()
|
|
|
|
def test_does_not_rely_on_server_content_type(
|
|
self, source, candidate, settings, image_request_mock
|
|
) -> None:
|
|
image_request_mock.get(
|
|
self.URL, content_type="image/jpeg", file_type="image/png"
|
|
)
|
|
source.fetch_image(candidate, settings)
|
|
assert candidate.path is not None
|
|
assert os.path.splitext(candidate.path)[1] == b".png"
|
|
assert Path(os.fsdecode(candidate.path)).exists()
|
|
|
|
|
|
class TestFSArt(UseThePlugin):
|
|
@pytest.fixture
|
|
def settings(self) -> Settings:
|
|
return Settings(cautious=False, cover_names=("art",), fallback=None)
|
|
|
|
@pytest.fixture
|
|
def dpath(self) -> bytes:
|
|
dpath = os.path.join(self.temp_dir, b"arttest")
|
|
os.mkdir(syspath(dpath))
|
|
return dpath
|
|
|
|
@pytest.fixture
|
|
def source(self) -> fetchart.FileSystem:
|
|
return fetchart.FileSystem(logger, self.plugin.config)
|
|
|
|
def test_finds_jpg_in_directory(self, source, dpath, settings) -> None:
|
|
_common.touch(os.path.join(dpath, b"a.jpg"))
|
|
candidate = next(source.get(Album(), settings, [dpath]))
|
|
assert candidate.path == os.path.join(dpath, b"a.jpg")
|
|
|
|
def test_appropriately_named_file_takes_precedence(
|
|
self, source, dpath, settings
|
|
) -> None:
|
|
_common.touch(os.path.join(dpath, b"a.jpg"))
|
|
_common.touch(os.path.join(dpath, b"art.jpg"))
|
|
candidate = next(source.get(Album(), settings, [dpath]))
|
|
assert candidate.path == os.path.join(dpath, b"art.jpg")
|
|
|
|
def test_non_image_file_not_identified(
|
|
self, source, dpath, settings
|
|
) -> None:
|
|
_common.touch(os.path.join(dpath, b"a.txt"))
|
|
with pytest.raises(StopIteration):
|
|
next(source.get(Album(), settings, [dpath]))
|
|
|
|
def test_cautious_skips_fallback(self, source, dpath, settings) -> None:
|
|
_common.touch(os.path.join(dpath, b"a.jpg"))
|
|
settings.cautious = True
|
|
with pytest.raises(StopIteration):
|
|
next(source.get(Album(), settings, [dpath]))
|
|
|
|
def test_configured_fallback_is_used(self, source, dpath, settings) -> None:
|
|
fallback = os.path.join(self.temp_dir, b"a.jpg")
|
|
_common.touch(fallback)
|
|
settings.fallback = fallback # type: ignore
|
|
candidate = next(source.get(Album(), settings, [dpath]))
|
|
assert candidate.path == fallback
|
|
|
|
def test_empty_dir(self, source, dpath, settings) -> None:
|
|
with pytest.raises(StopIteration):
|
|
next(source.get(Album(), settings, [dpath]))
|
|
|
|
def test_precedence_amongst_correct_files(
|
|
self, source, dpath, settings
|
|
) -> None:
|
|
images = [b"front-cover.jpg", b"front.jpg", b"back.jpg"]
|
|
paths = [os.path.join(dpath, i) for i in images]
|
|
for p in paths:
|
|
_common.touch(p)
|
|
settings.cover_names = [b"cover", b"front", b"back"]
|
|
candidates = [
|
|
candidate.path
|
|
for candidate in source.get(Album(), settings, [dpath])
|
|
]
|
|
assert candidates == paths
|
|
|
|
@patch("os.path.samefile")
|
|
def test_is_candidate_fallback_os_error(
|
|
self, mock_samefile, source
|
|
) -> None:
|
|
mock_samefile.side_effect = OSError("os error")
|
|
fallback = os.path.join(self.temp_dir, b"a.jpg")
|
|
self.plugin.fallback = str(fallback)
|
|
candidate = fetchart.Candidate(logger, source.ID, fallback)
|
|
result = self.plugin._is_candidate_fallback(candidate)
|
|
mock_samefile.assert_called_once()
|
|
assert not result
|
|
|
|
|
|
class TestCombined(UseThePlugin, FetchImageHelper, CAAData):
|
|
ASIN = "xxxx"
|
|
MBID = "releaseid"
|
|
AMAZON_URL = f"https://images.amazon.com/images/P/{ASIN}.01.LZZZZZZZ.jpg"
|
|
AMAZON_URL_2 = f"https://images.amazon.com/images/P/{ASIN}.02.LZZZZZZZ.jpg"
|
|
AAO_URL = f"https://www.albumart.org/index_detail.php?asin={ASIN}"
|
|
|
|
@pytest.fixture
|
|
def dpath(self):
|
|
dpath = os.path.join(self.temp_dir, b"arttest")
|
|
os.mkdir(syspath(dpath))
|
|
return dpath
|
|
|
|
def test_main_interface_returns_amazon_art(self, image_request_mock):
|
|
image_request_mock.get(self.AMAZON_URL)
|
|
album = Album(asin=self.ASIN)
|
|
candidate = self.plugin.art_for_album(album, None)
|
|
assert candidate is not None
|
|
|
|
def test_main_interface_returns_none_for_missing_asin_and_path(self):
|
|
album = Album()
|
|
candidate = self.plugin.art_for_album(album, None)
|
|
assert candidate is None
|
|
|
|
def test_main_interface_gives_precedence_to_fs_art(
|
|
self, dpath, image_request_mock
|
|
):
|
|
_common.touch(os.path.join(dpath, b"art.jpg"))
|
|
image_request_mock.get(self.AMAZON_URL)
|
|
album = Album(asin=self.ASIN)
|
|
candidate = self.plugin.art_for_album(album, [dpath])
|
|
assert candidate is not None
|
|
assert candidate.path == os.path.join(dpath, b"art.jpg")
|
|
|
|
def test_main_interface_falls_back_to_amazon(
|
|
self, dpath, image_request_mock
|
|
):
|
|
image_request_mock.get(self.AMAZON_URL)
|
|
album = Album(asin=self.ASIN)
|
|
candidate = self.plugin.art_for_album(album, [dpath])
|
|
assert candidate is not None
|
|
assert not candidate.path.startswith(dpath)
|
|
|
|
def test_main_interface_tries_amazon_before_aao(
|
|
self, dpath, image_request_mock
|
|
):
|
|
image_request_mock.get(self.AMAZON_URL)
|
|
album = Album(asin=self.ASIN)
|
|
self.plugin.art_for_album(album, [dpath])
|
|
assert image_request_mock.mocker.called_once
|
|
assert (
|
|
image_request_mock.mocker.request_history[0].url == self.AMAZON_URL
|
|
)
|
|
|
|
def test_main_interface_falls_back_to_aao(self, dpath, image_request_mock):
|
|
image_request_mock.get(self.AMAZON_URL, content_type="text/html")
|
|
image_request_mock.get(self.AMAZON_URL_2, content_type="text/html")
|
|
image_request_mock.get(self.AAO_URL, content_type="image/jpeg")
|
|
album = Album(asin=self.ASIN)
|
|
self.plugin.art_for_album(album, [dpath])
|
|
assert image_request_mock.mocker.request_history[-1].url == self.AAO_URL
|
|
|
|
def test_main_interface_uses_caa_when_mbid_available(
|
|
self, image_request_mock
|
|
):
|
|
image_request_mock.get(self.RELEASE_URL, content=self.RESPONSE_RELEASE)
|
|
image_request_mock.get(self.GROUP_URL, content=self.RESPONSE_GROUP)
|
|
image_request_mock.get(
|
|
"http://coverartarchive.org/release/rid/12345.gif",
|
|
content_type="image/gif",
|
|
)
|
|
image_request_mock.get(
|
|
"http://coverartarchive.org/release/rid/12345.jpg",
|
|
content_type="image/jpeg",
|
|
)
|
|
album = Album(
|
|
mb_albumid=self.MBID_RELASE,
|
|
mb_releasegroupid=self.MBID_GROUP,
|
|
asin=self.ASIN,
|
|
)
|
|
candidate = self.plugin.art_for_album(album, None)
|
|
assert candidate is not None
|
|
assert image_request_mock.mocker.call_count == 3
|
|
assert (
|
|
image_request_mock.mocker.request_history[0].url == self.RELEASE_URL
|
|
)
|
|
|
|
def test_local_only_does_not_access_network(self, image_request_mock):
|
|
album = Album(mb_albumid=self.MBID, asin=self.ASIN)
|
|
self.plugin.art_for_album(album, None, local_only=True)
|
|
assert not image_request_mock.mocker.called
|
|
|
|
def test_local_only_gets_fs_image(self, dpath, image_request_mock):
|
|
_common.touch(os.path.join(dpath, b"art.jpg"))
|
|
album = Album(mb_albumid=self.MBID, asin=self.ASIN)
|
|
candidate = self.plugin.art_for_album(album, [dpath], local_only=True)
|
|
assert candidate is not None
|
|
assert candidate.path == os.path.join(dpath, b"art.jpg")
|
|
assert not image_request_mock.mocker.called
|
|
|
|
|
|
class TestAAO(UseThePlugin, FetchImageHelper):
|
|
ASIN = "xxxx"
|
|
AAO_URL = f"https://www.albumart.org/index_detail.php?asin={ASIN}"
|
|
|
|
@pytest.fixture
|
|
def settings(self) -> Settings:
|
|
return Settings()
|
|
|
|
@pytest.fixture
|
|
def source(self) -> fetchart.AlbumArtOrg:
|
|
return fetchart.AlbumArtOrg(logger, self.plugin.config)
|
|
|
|
def test_aao_scraper_finds_image(
|
|
self, source, settings, image_request_mock
|
|
) -> None:
|
|
body = """
|
|
<br />
|
|
<a href="TARGET_URL" title="View larger image"
|
|
class="thickbox" style="color: #7E9DA2; text-decoration:none;">
|
|
<img src="http://www.albumart.org/images/zoom-icon.jpg"
|
|
alt="View larger image" width="17" height="15" border="0"/></a>
|
|
"""
|
|
image_request_mock.get(
|
|
self.AAO_URL, content=body, content_type="text/html"
|
|
)
|
|
album = Album(asin=self.ASIN)
|
|
candidate = next(source.get(album, settings, []))
|
|
assert candidate.url == "TARGET_URL"
|
|
|
|
def test_aao_scraper_returns_no_result_when_no_image_present(
|
|
self, source, settings, image_request_mock
|
|
) -> None:
|
|
image_request_mock.get(
|
|
self.AAO_URL, content="blah blah", content_type="text/html"
|
|
)
|
|
album = Album(asin=self.ASIN)
|
|
with pytest.raises(StopIteration):
|
|
next(source.get(album, settings, []))
|
|
|
|
|
|
class TestITunesStore(UseThePlugin, FetchImageHelper):
|
|
@pytest.fixture
|
|
def settings(self) -> Settings:
|
|
return Settings()
|
|
|
|
@pytest.fixture
|
|
def source(self) -> fetchart.ITunesStore:
|
|
return fetchart.ITunesStore(logger, self.plugin.config)
|
|
|
|
@pytest.fixture
|
|
def album(self) -> Album:
|
|
return Album(albumartist="some artist", album="some album")
|
|
|
|
def test_itunesstore_finds_image(
|
|
self, source, settings, album, image_request_mock
|
|
):
|
|
json = """{
|
|
"results":
|
|
[
|
|
{
|
|
"artistName": "some artist",
|
|
"collectionName": "some album",
|
|
"artworkUrl100": "url_to_the_image"
|
|
}
|
|
]
|
|
}"""
|
|
image_request_mock.get(
|
|
fetchart.ITunesStore.API_URL,
|
|
content=json,
|
|
content_type="application/json",
|
|
)
|
|
candidate = next(source.get(album, settings, []))
|
|
assert candidate.url == "url_to_the_image"
|
|
assert candidate.match == fetchart.MetadataMatch.EXACT
|
|
|
|
def test_itunesstore_no_result(
|
|
self, source, settings, album, image_request_mock, caplog
|
|
):
|
|
json = '{"results": []}'
|
|
image_request_mock.get(
|
|
fetchart.ITunesStore.API_URL,
|
|
content=json,
|
|
content_type="application/json",
|
|
)
|
|
expected = "got no results"
|
|
|
|
with caplog.at_level("DEBUG", logger="beets.test_art"):
|
|
with pytest.raises(StopIteration):
|
|
next(source.get(album, settings, []))
|
|
assert expected in caplog.messages[1]
|
|
|
|
def test_itunesstore_requestexception(
|
|
self, source, settings, album, requests_mock, caplog
|
|
):
|
|
requests_mock.get(
|
|
fetchart.ITunesStore.API_URL,
|
|
json={"error": "not found"},
|
|
status_code=404,
|
|
)
|
|
expected = "iTunes search failed: 404 Client Error"
|
|
|
|
with caplog.at_level("DEBUG", logger="beets.test_art"):
|
|
with pytest.raises(StopIteration):
|
|
next(source.get(album, settings, []))
|
|
assert expected in caplog.messages[1]
|
|
|
|
def test_itunesstore_fallback_match(
|
|
self, source: fetchart.ITunesStore, settings, album, image_request_mock
|
|
):
|
|
json = """{
|
|
"results":
|
|
[
|
|
{
|
|
"collectionName": "some album",
|
|
"artworkUrl100": "url_to_the_image"
|
|
}
|
|
]
|
|
}"""
|
|
image_request_mock.get(
|
|
fetchart.ITunesStore.API_URL,
|
|
content=json,
|
|
content_type="application/json",
|
|
)
|
|
candidate = next(source.get(album, settings, []))
|
|
assert candidate.url == "url_to_the_image"
|
|
assert candidate.match == fetchart.MetadataMatch.FALLBACK
|
|
|
|
def test_itunesstore_returns_result_without_artwork(
|
|
self, source, settings, album, image_request_mock, caplog
|
|
):
|
|
json = """{
|
|
"results":
|
|
[
|
|
{
|
|
"artistName": "some artist",
|
|
"collectionName": "some album"
|
|
}
|
|
]
|
|
}"""
|
|
image_request_mock.get(
|
|
fetchart.ITunesStore.API_URL,
|
|
content=json,
|
|
content_type="application/json",
|
|
)
|
|
expected = "Malformed itunes candidate"
|
|
|
|
with caplog.at_level("DEBUG", logger="beets.test_art"):
|
|
with pytest.raises(StopIteration):
|
|
next(source.get(album, settings, []))
|
|
assert expected in caplog.messages[1]
|
|
|
|
def test_itunesstore_returns_no_result_when_error_received(
|
|
self, source, settings, album, image_request_mock, caplog
|
|
):
|
|
json = '{"error": {"errors": [{"reason": "some reason"}]}}'
|
|
image_request_mock.get(
|
|
fetchart.ITunesStore.API_URL,
|
|
content=json,
|
|
content_type="application/json",
|
|
)
|
|
expected = "not found in json. Fields are"
|
|
|
|
with caplog.at_level("DEBUG", logger="beets.test_art"):
|
|
with pytest.raises(StopIteration):
|
|
next(source.get(album, settings, []))
|
|
assert expected in caplog.messages[1]
|
|
|
|
def test_itunesstore_returns_no_result_with_malformed_response(
|
|
self, source, settings, album, image_request_mock, caplog
|
|
):
|
|
json = """bla blup"""
|
|
image_request_mock.get(
|
|
fetchart.ITunesStore.API_URL,
|
|
content=json,
|
|
content_type="application/json",
|
|
)
|
|
expected = "Could not decode json response:"
|
|
|
|
with caplog.at_level("DEBUG", logger="beets.test_art"):
|
|
with pytest.raises(StopIteration):
|
|
next(source.get(album, settings, []))
|
|
assert expected in caplog.messages[1]
|
|
|
|
|
|
class TestGoogleImage(UseThePlugin, FetchImageHelper):
|
|
@pytest.fixture
|
|
def settings(self) -> Settings:
|
|
return Settings()
|
|
|
|
@pytest.fixture
|
|
def source(self) -> fetchart.GoogleImages:
|
|
return fetchart.GoogleImages(logger, self.plugin.config)
|
|
|
|
def test_google_art_finds_image(self, source, settings, image_request_mock):
|
|
album = Album(albumartist="some artist", album="some album")
|
|
json = '{"items": [{"link": "url_to_the_image"}]}'
|
|
image_request_mock.get(
|
|
fetchart.GoogleImages.URL,
|
|
content=json,
|
|
content_type="application/json",
|
|
)
|
|
candidate = next(source.get(album, settings, []))
|
|
assert candidate.url == "url_to_the_image"
|
|
|
|
def test_google_art_returns_no_result_when_error_received(
|
|
self, source, settings, image_request_mock
|
|
):
|
|
album = Album(albumartist="some artist", album="some album")
|
|
json = '{"error": {"errors": [{"reason": "some reason"}]}}'
|
|
image_request_mock.get(
|
|
fetchart.GoogleImages.URL,
|
|
content=json,
|
|
content_type="application/json",
|
|
)
|
|
with pytest.raises(StopIteration):
|
|
next(source.get(album, settings, []))
|
|
|
|
def test_google_art_returns_no_result_with_malformed_response(
|
|
self, source, settings, image_request_mock
|
|
):
|
|
album = Album(albumartist="some artist", album="some album")
|
|
json = """bla blup"""
|
|
image_request_mock.get(
|
|
fetchart.GoogleImages.URL,
|
|
content=json,
|
|
content_type="application/json",
|
|
)
|
|
with pytest.raises(StopIteration):
|
|
next(source.get(album, settings, []))
|
|
|
|
|
|
class TestCoverArtArchive(UseThePlugin, FetchImageHelper, CAAData):
|
|
@pytest.fixture
|
|
def settings(self) -> Settings:
|
|
return Settings(maxwidth=0)
|
|
|
|
@pytest.fixture
|
|
def source(self) -> fetchart.CoverArtArchive:
|
|
return fetchart.CoverArtArchive(logger, self.plugin.config)
|
|
|
|
def test_caa_finds_image(self, source, settings, image_request_mock):
|
|
album = Album(
|
|
mb_albumid=self.MBID_RELASE, mb_releasegroupid=self.MBID_GROUP
|
|
)
|
|
image_request_mock.get(self.RELEASE_URL, content=self.RESPONSE_RELEASE)
|
|
image_request_mock.get(self.GROUP_URL, content=self.RESPONSE_GROUP)
|
|
candidates = list(source.get(album, settings, []))
|
|
assert len(candidates) == 3
|
|
assert image_request_mock.mocker.call_count == 2
|
|
assert (
|
|
image_request_mock.mocker.request_history[0].url == self.RELEASE_URL
|
|
)
|
|
|
|
def test_fetchart_uses_caa_pre_sized_maxwidth_thumbs(
|
|
self, source, image_request_mock
|
|
):
|
|
# CAA provides pre-sized thumbnails of width 250px, 500px, and 1200px
|
|
# We only test with one of them here
|
|
maxwidth = 1200
|
|
settings = Settings(maxwidth=maxwidth)
|
|
|
|
album = Album(
|
|
mb_albumid=self.MBID_RELASE, mb_releasegroupid=self.MBID_GROUP
|
|
)
|
|
image_request_mock.get(self.RELEASE_URL, content=self.RESPONSE_RELEASE)
|
|
image_request_mock.get(self.GROUP_URL, content=self.RESPONSE_GROUP)
|
|
candidates = list(source.get(album, settings, []))
|
|
assert len(candidates) == 3
|
|
for candidate in candidates:
|
|
assert candidate.url is not None
|
|
assert f"-{maxwidth}.jpg" in candidate.url
|
|
|
|
def test_caa_finds_image_if_maxwidth_is_set_and_thumbnails_is_empty(
|
|
self, source, image_request_mock
|
|
):
|
|
# CAA provides pre-sized thumbnails of width 250px, 500px, and 1200px
|
|
# We only test with one of them here
|
|
maxwidth = 1200
|
|
settings = Settings(maxwidth=maxwidth)
|
|
|
|
album = Album(
|
|
mb_albumid=self.MBID_RELASE, mb_releasegroupid=self.MBID_GROUP
|
|
)
|
|
image_request_mock.get(
|
|
self.RELEASE_URL, content=self.RESPONSE_RELEASE_WITHOUT_THUMBNAILS
|
|
)
|
|
image_request_mock.get(
|
|
self.GROUP_URL, content=self.RESPONSE_GROUP_WITHOUT_THUMBNAILS
|
|
)
|
|
candidates = list(source.get(album, settings, []))
|
|
assert len(candidates) == 3
|
|
for candidate in candidates:
|
|
assert candidate.url is not None
|
|
assert f"-{maxwidth}.jpg" not in candidate.url
|
|
|
|
|
|
class TestFanartTV(UseThePlugin, FetchImageHelper):
|
|
RESPONSE_MULTIPLE = """{
|
|
"name": "artistname",
|
|
"mbid_id": "artistid",
|
|
"albums": {
|
|
"thereleasegroupid": {
|
|
"albumcover": [
|
|
{
|
|
"id": "24",
|
|
"url": "http://example.com/1.jpg",
|
|
"likes": "0"
|
|
},
|
|
{
|
|
"id": "42",
|
|
"url": "http://example.com/2.jpg",
|
|
"likes": "0"
|
|
},
|
|
{
|
|
"id": "23",
|
|
"url": "http://example.com/3.jpg",
|
|
"likes": "0"
|
|
}
|
|
],
|
|
"cdart": [
|
|
{
|
|
"id": "123",
|
|
"url": "http://example.com/4.jpg",
|
|
"likes": "0",
|
|
"disc": "1",
|
|
"size": "1000"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}"""
|
|
RESPONSE_NO_ART = """{
|
|
"name": "artistname",
|
|
"mbid_id": "artistid",
|
|
"albums": {
|
|
"thereleasegroupid": {
|
|
"cdart": [
|
|
{
|
|
"id": "123",
|
|
"url": "http://example.com/4.jpg",
|
|
"likes": "0",
|
|
"disc": "1",
|
|
"size": "1000"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}"""
|
|
RESPONSE_ERROR = """{
|
|
"status": "error",
|
|
"error message": "the error message"
|
|
}"""
|
|
RESPONSE_MALFORMED = "bla blup"
|
|
|
|
@pytest.fixture
|
|
def settings(self):
|
|
return Settings(maxwidth=0)
|
|
|
|
@pytest.fixture
|
|
def source(self):
|
|
return fetchart.FanartTV(logger, self.plugin.config)
|
|
|
|
def test_fanarttv_finds_image(self, source, settings, image_request_mock):
|
|
album = Album(mb_releasegroupid="thereleasegroupid")
|
|
image_request_mock.get(
|
|
f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid",
|
|
content=self.RESPONSE_MULTIPLE,
|
|
content_type="application/json",
|
|
)
|
|
candidate = next(source.get(album, settings, []))
|
|
assert candidate.url == "http://example.com/1.jpg"
|
|
|
|
def test_fanarttv_returns_no_result_when_error_received(
|
|
self, source, settings, image_request_mock
|
|
):
|
|
album = Album(mb_releasegroupid="thereleasegroupid")
|
|
image_request_mock.get(
|
|
f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid",
|
|
content=self.RESPONSE_ERROR,
|
|
content_type="application/json",
|
|
)
|
|
with pytest.raises(StopIteration):
|
|
next(source.get(album, settings, []))
|
|
|
|
def test_fanarttv_returns_no_result_with_malformed_response(
|
|
self, source, settings, image_request_mock
|
|
):
|
|
album = Album(mb_releasegroupid="thereleasegroupid")
|
|
image_request_mock.get(
|
|
f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid",
|
|
content=self.RESPONSE_MALFORMED,
|
|
content_type="application/json",
|
|
)
|
|
with pytest.raises(StopIteration):
|
|
next(source.get(album, settings, []))
|
|
|
|
def test_fanarttv_only_other_images(
|
|
self, source, settings, image_request_mock
|
|
):
|
|
# The source used to fail when there were images present, but no cover
|
|
album = Album(mb_releasegroupid="thereleasegroupid")
|
|
image_request_mock.get(
|
|
f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid",
|
|
content=self.RESPONSE_NO_ART,
|
|
content_type="application/json",
|
|
)
|
|
with pytest.raises(StopIteration):
|
|
next(source.get(album, settings, []))
|
|
|
|
|
|
class TestArtImporter(UseThePlugin):
|
|
@pytest.fixture(autouse=True)
|
|
def _setup(self, setup_plugin):
|
|
|
|
# Mock the album art fetcher to always return our test file.
|
|
self.art_file = self.temp_dir_path / "tmpcover.jpg"
|
|
self.art_file.touch()
|
|
self.old_afa = self.plugin.art_for_album
|
|
self.afa_response = fetchart.Candidate(
|
|
logger, source_name="test", path=self.art_file
|
|
)
|
|
|
|
def art_for_album(i, p, local_only=False):
|
|
return self.afa_response
|
|
|
|
self.plugin.art_for_album = art_for_album
|
|
|
|
# Test library.
|
|
os.mkdir(syspath(os.path.join(self.libdir, b"album")))
|
|
itempath = os.path.join(self.libdir, b"album", b"test.mp3")
|
|
shutil.copyfile(
|
|
syspath(os.path.join(_common.RSRC, b"full.mp3")), syspath(itempath)
|
|
)
|
|
self.i = _common.item()
|
|
self.i.path = itempath
|
|
self.album = self.lib.add_album([self.i])
|
|
self.lib._connection().commit()
|
|
|
|
# The import configuration.
|
|
self.session = _common.import_session(self.lib)
|
|
|
|
# Import task for the coroutine.
|
|
self.task = importer.ImportTask(None, None, [self.i])
|
|
self.task.is_album = True
|
|
self.task.album = self.album
|
|
info = AlbumInfo(
|
|
album="some album",
|
|
album_id="albumid",
|
|
artist="some artist",
|
|
artist_id="artistid",
|
|
tracks=[],
|
|
)
|
|
self.task.set_choice(AlbumMatch(Distance(), info, {}))
|
|
yield
|
|
self.plugin.art_for_album = self.old_afa
|
|
|
|
def _fetch_art(self, should_exist):
|
|
"""Execute the fetch_art coroutine for the task and return the
|
|
album's resulting artpath. ``should_exist`` specifies whether to
|
|
assert that art path was set (to the correct value) or or that
|
|
the path was not set.
|
|
"""
|
|
# Execute the two relevant parts of the importer.
|
|
self.plugin.fetch_art(self.session, self.task)
|
|
self.plugin.assign_art(self.session, self.task)
|
|
|
|
artpath = self.lib.albums()[0].art_filepath
|
|
if should_exist:
|
|
assert artpath == self.i.filepath.parent / "cover.jpg"
|
|
assert artpath.exists()
|
|
else:
|
|
assert artpath is None
|
|
return artpath
|
|
|
|
def test_fetch_art(self):
|
|
assert not self.lib.albums()[0].artpath
|
|
self._fetch_art(True)
|
|
|
|
def test_art_not_found(self):
|
|
self.afa_response = None
|
|
self._fetch_art(False)
|
|
|
|
def test_no_art_for_singleton(self):
|
|
self.task.is_album = False
|
|
self._fetch_art(False)
|
|
|
|
def test_leave_original_file_in_place(self):
|
|
self._fetch_art(True)
|
|
assert self.art_file.exists()
|
|
|
|
def test_delete_original_file(self):
|
|
prev_move = config["import"]["move"].get()
|
|
try:
|
|
config["import"]["move"] = True
|
|
self._fetch_art(True)
|
|
assert not self.art_file.exists()
|
|
finally:
|
|
config["import"]["move"] = prev_move
|
|
|
|
def test_do_not_delete_original_if_already_in_place(self):
|
|
artdest = os.path.join(os.path.dirname(self.i.path), b"cover.jpg")
|
|
shutil.copyfile(self.art_file, syspath(artdest))
|
|
self.afa_response = fetchart.Candidate(
|
|
logger, source_name="test", path=artdest
|
|
)
|
|
self._fetch_art(True)
|
|
|
|
def test_fetch_art_if_imported_file_deleted(self):
|
|
# See #1126. Test the following scenario:
|
|
# - Album art imported, `album.artpath` set.
|
|
# - Imported album art file subsequently deleted (by user or other
|
|
# program).
|
|
# `fetchart` should import album art again instead of printing the
|
|
# message "<album> has album art".
|
|
self._fetch_art(True)
|
|
util.remove(self.album.artpath)
|
|
self.plugin.batch_fetch_art(
|
|
self.lib, self.lib.albums(), force=False, quiet=False
|
|
)
|
|
assert self.album.art_filepath.exists()
|
|
|
|
|
|
class AlbumArtOperationMixin(UseThePlugin):
|
|
"""Base test case for album art operations.
|
|
|
|
Provides common setup for testing album art processing operations by setting
|
|
up a mock filesystem source that returns a predefined test image.
|
|
"""
|
|
|
|
IMAGE_PATH = os.path.join(_common.RSRC, b"abbey-similar.jpg")
|
|
IMAGE_FILESIZE = os.stat(util.syspath(IMAGE_PATH)).st_size
|
|
IMAGE_WIDTH = 500
|
|
IMAGE_HEIGHT = 490
|
|
IMAGE_WIDTH_HEIGHT_DIFF = IMAGE_WIDTH - IMAGE_HEIGHT
|
|
|
|
@pytest.fixture(autouse=True, scope="class")
|
|
def fs_mock(self, cleanup):
|
|
def fs_source_get(_self, album, settings, paths):
|
|
if paths:
|
|
yield fetchart.Candidate(
|
|
logger, source_name=_self.ID, path=self.IMAGE_PATH
|
|
)
|
|
|
|
with patch("beetsplug.fetchart.FileSystem.get", fs_source_get):
|
|
yield
|
|
|
|
def get_album_art(self):
|
|
return self.plugin.art_for_album(Album(), [""], True)
|
|
|
|
|
|
@REQUIRES_ARTRESIZER
|
|
class TestAlbumArtOperationConfiguration(AlbumArtOperationMixin):
|
|
"""Check that scale & filesize configuration is respected.
|
|
|
|
Depending on `minwidth`, `enforce_ratio`, `margin_px`, and `margin_percent`
|
|
configuration the plugin should or should not return an art candidate.
|
|
"""
|
|
|
|
def test_minwidth(self):
|
|
self.plugin.minwidth = self.IMAGE_WIDTH / 2
|
|
assert self.get_album_art()
|
|
|
|
self.plugin.minwidth = self.IMAGE_WIDTH * 2
|
|
assert not self.get_album_art()
|
|
|
|
def test_enforce_ratio(self):
|
|
self.plugin.enforce_ratio = True
|
|
assert not self.get_album_art()
|
|
|
|
self.plugin.enforce_ratio = False
|
|
assert self.get_album_art()
|
|
|
|
def test_enforce_ratio_with_px_margin(self):
|
|
self.plugin.enforce_ratio = True
|
|
|
|
self.plugin.margin_px = self.IMAGE_WIDTH_HEIGHT_DIFF * 0.5
|
|
assert not self.get_album_art()
|
|
|
|
self.plugin.margin_px = self.IMAGE_WIDTH_HEIGHT_DIFF * 1.5
|
|
assert self.get_album_art()
|
|
|
|
def test_enforce_ratio_with_percent_margin(self):
|
|
self.plugin.enforce_ratio = True
|
|
diff_by_width = self.IMAGE_WIDTH_HEIGHT_DIFF / self.IMAGE_WIDTH
|
|
|
|
self.plugin.margin_percent = diff_by_width * 0.5
|
|
assert not self.get_album_art()
|
|
|
|
self.plugin.margin_percent = diff_by_width * 1.5
|
|
assert self.get_album_art()
|
|
|
|
|
|
@REQUIRES_ARTRESIZER
|
|
class TestAlbumArtPerformOperation(AlbumArtOperationMixin):
|
|
"""Test that the art is resized and deinterlaced if necessary."""
|
|
|
|
@pytest.fixture
|
|
def resizer_mock(self) -> Iterator[MagicMock]:
|
|
with patch.object(
|
|
ArtResizer.shared, "resize", return_value=self.IMAGE_PATH
|
|
) as mocked:
|
|
yield mocked
|
|
|
|
@pytest.fixture
|
|
def deinterlacer_mock(self) -> Iterator[MagicMock]:
|
|
with patch.object(
|
|
ArtResizer.shared, "deinterlace", return_value=self.IMAGE_PATH
|
|
) as mocked:
|
|
yield mocked
|
|
|
|
def test_resize(self, resizer_mock):
|
|
self.plugin.maxwidth = self.IMAGE_WIDTH / 2
|
|
assert self.get_album_art()
|
|
resizer_mock.assert_called_once()
|
|
|
|
def test_file_resized(self, resizer_mock):
|
|
self.plugin.max_filesize = self.IMAGE_FILESIZE // 2
|
|
assert self.get_album_art()
|
|
resizer_mock.assert_called_once()
|
|
|
|
def test_file_not_resized(self, resizer_mock):
|
|
self.plugin.max_filesize = self.IMAGE_FILESIZE
|
|
assert self.get_album_art()
|
|
resizer_mock.assert_not_called()
|
|
|
|
def test_file_resized_but_not_scaled(self, resizer_mock):
|
|
self.plugin.maxwidth = self.IMAGE_WIDTH * 2
|
|
self.plugin.max_filesize = self.IMAGE_FILESIZE // 2
|
|
assert self.get_album_art()
|
|
resizer_mock.assert_called_once()
|
|
|
|
def test_file_resized_and_scaled(self, resizer_mock):
|
|
self.plugin.maxwidth = self.IMAGE_WIDTH / 2
|
|
self.plugin.max_filesize = self.IMAGE_FILESIZE // 2
|
|
assert self.get_album_art()
|
|
assert resizer_mock.call_count == 2
|
|
|
|
def test_deinterlaced(self, deinterlacer_mock):
|
|
self.plugin.deinterlace = True
|
|
assert self.get_album_art()
|
|
deinterlacer_mock.assert_called_once()
|
|
|
|
def test_not_deinterlaced(self, deinterlacer_mock):
|
|
self.plugin.deinterlace = False
|
|
assert self.get_album_art()
|
|
deinterlacer_mock.assert_not_called()
|
|
|
|
def test_deinterlaced_and_resized(self, resizer_mock, deinterlacer_mock):
|
|
self.plugin.maxwidth = self.IMAGE_WIDTH / 2
|
|
self.plugin.deinterlace = True
|
|
assert self.get_album_art()
|
|
deinterlacer_mock.assert_called_once()
|
|
resizer_mock.assert_called_once()
|
|
|
|
|
|
class TestDeprecatedConfig:
|
|
"""While refactoring the plugin, the remote_priority option was deprecated,
|
|
and a new codepath should translate its effect. Check that it actually does
|
|
so.
|
|
"""
|
|
|
|
# If we subclassed UseThePlugin, the configuration change would either be
|
|
# overwritten by BeetsTestCase or be set after constructing the
|
|
# plugin object
|
|
@pytest.fixture(autouse=True)
|
|
def setup(self):
|
|
config["fetchart"]["remote_priority"] = True
|
|
self.plugin = fetchart.FetchArtPlugin()
|
|
|
|
def test_moves_filesystem_to_end(self):
|
|
assert isinstance(self.plugin.sources[-1], fetchart.FileSystem)
|
|
|
|
|
|
class TestEnforceRatioConfig:
|
|
"""Throw some data at the regexes."""
|
|
|
|
def _load_with_config(self, values, should_raise):
|
|
if should_raise:
|
|
for v in values:
|
|
config["fetchart"]["enforce_ratio"] = v
|
|
with pytest.raises(confuse.ConfigValueError):
|
|
fetchart.FetchArtPlugin()
|
|
else:
|
|
for v in values:
|
|
config["fetchart"]["enforce_ratio"] = v
|
|
fetchart.FetchArtPlugin()
|
|
|
|
def test_px(self):
|
|
self._load_with_config("0px 4px 12px 123px".split(), False)
|
|
self._load_with_config("00px stuff5px".split(), True)
|
|
|
|
def test_percent(self):
|
|
self._load_with_config("0% 0.00% 5.1% 5% 100%".split(), False)
|
|
self._load_with_config("00% 1.234% foo5% 100.1%".split(), True)
|