Skip to content
83 changes: 70 additions & 13 deletions beetsplug/fetchart.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from beets.util.artresizer import ArtResizer
from beets.util.color import colorize
from beets.util.config import UnknownPairError, sanitize_pairs
from beetsplug._utils import art

if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence
Expand Down Expand Up @@ -771,11 +772,11 @@ def get(

matches = []
# can there be more than one releasegroupid per response?
for mbid, art in data.get("albums", {}).items():
for mbid, images in data.get("albums", {}).items():
# there might be more art referenced, e.g. cdart, and an albumcover
# might not be present, even if the request was successful
if album.mb_releasegroupid == mbid and "albumcover" in art:
matches.extend(art["albumcover"])
if album.mb_releasegroupid == mbid and "albumcover" in images:
matches.extend(images["albumcover"])
# can this actually occur?
else:
self._log.debug(
Expand Down Expand Up @@ -1285,9 +1286,49 @@ def get(
return


class Embedded(LocalArtSource):
NAME = "Embedded"
ID = "embedded"

def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)

def cleanup(self, candidate: Candidate) -> None:
if candidate.path:
try:
util.remove(path=candidate.path)
except util.FilesystemError as exc:
self._log.debug("error cleaning up tmp art: {}", exc)

def get(
self,
album: Album,
plugin: FetchArtPlugin,
paths: None | Sequence[bytes],
) -> Iterator[Candidate]:
filename = get_temp_filename(__name__)
for item in album.items():
if extracted_path := art.extract(self._log, filename, item):
yield self._candidate(
path=extracted_path, match=MetadataMatch.EXACT
)
break
else:
self._log.debug("embedded: art not found for {}", album)

try:
# Always remove tempfile because art.extract will have created a new
# file with the same basename but correct file extension added
util.remove(path=filename)
except util.FilesystemError as exc:
self._log.debug("error cleaning up tempfile: {}", exc)
return


# All art sources. The order they will be tried in is specified by the config.
ART_SOURCES: set[type[ArtSource]] = {
FileSystem,
Embedded,
CoverArtArchive,
ITunesStore,
AlbumArtOrg,
Expand Down Expand Up @@ -1318,6 +1359,8 @@ def __init__(self) -> None:
self.config.add(
{
"auto": True,
"fetch_for_asis": False,
"skip_embedded": False,
"minwidth": 0,
"maxwidth": 0,
"quality": 0,
Expand Down Expand Up @@ -1438,6 +1481,14 @@ def _get_sources(self) -> list[tuple[str, str]]:
except UnknownPairError as e:
raise UserError(e)

@cached_property
def fetch_for_asis(self) -> bool:
return self.config["fetch_for_asis"].get(bool)

@cached_property
def skip_embedded(self) -> bool:
return self.config["skip_embedded"].get(bool)

@staticmethod
def _is_source_file_removal_enabled() -> bool:
return config["import"]["delete"].get(bool) or config["import"][
Expand All @@ -1458,14 +1509,13 @@ def _is_candidate_fallback(self, candidate: Candidate) -> bool:
def fetch_art(self, session: ImportSession, task: ImportTask) -> None:
"""Find art for the album being imported."""
if task.is_album: # Only fetch art for full albums.
if task.album.artpath and os.path.isfile(
syspath(task.album.artpath)
):
if self.album_has_art(task.album):
# Album already has art (probably a re-import); skip it.
return
if task.choice_flag == importer.Action.ASIS:
# For as-is imports, don't search Web sources for art.
local = True
# For as-is imports, don't search Web sources for art,
# unless fetch_for_asis is set
local = not self.fetch_for_asis
elif task.choice_flag in (
importer.Action.APPLY,
importer.Action.RETAG,
Expand Down Expand Up @@ -1548,6 +1598,17 @@ def func(lib: Library, opts, args) -> None:
cmd.func = func
return [cmd]

def album_has_art(self, album: Album):
if album.artpath and os.path.isfile(syspath(album.artpath)):
self._log.debug("skipping {}: has stored artwork", album)
return True
if self.skip_embedded:
if any(art.get_art(self._log, item) for item in album.items()):
self._log.debug("skipping {}: has embedded artwork", album)
return True

return False

# Utilities converted from functions to methods on logging overhaul

def art_for_album(
Expand Down Expand Up @@ -1601,11 +1662,7 @@ def batch_fetch_art(
fetchart CLI command.
"""
for album in albums:
if (
album.artpath
and not force
and os.path.isfile(syspath(album.artpath))
):
if not force and self.album_has_art(album):
if not quiet:
message = colorize("text_highlight_minor", "has album art")
ui.print_(f"{album}: {message}")
Expand Down
8 changes: 8 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ New features
- :doc:`plugins/tidal`: Add cover art support. Album metadata now includes
``cover_art_url`` from Tidal's ``coverArt`` relationship, which the
:doc:`plugins/fetchart` plugin can retrieve.
- :doc:`plugins/fetchart`: Add ``fetch_for_asis`` setting that enables fetching
album art from online sources even when imported files are not modified by the
auto-tagger. Default is ``no`` which means ``fetchart`` looks for art only in
the local filesystem when the user (or ``quiet_fallback``) chooses ``asis``.
- :doc:`plugins/fetchart`: Add ``embedded`` source that extracts embedded art
for an album into the file named by :ref:`art-filename`. Add ``skip_embedded``
setting that allows ``fetchart`` to skip fetching art for files that already
have embedded art.

Bug fixes
~~~~~~~~~
Expand Down
5 changes: 5 additions & 0 deletions docs/plugins/embedart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ be embedded. Art will be embedded after each album has its cover art set.

This behavior can be disabled with the ``auto`` config option (see below).

Extracting Art Automatically
----------------------------

See the ``embedded`` source of the :doc:`/plugins/fetchart`.

.. _image-similarity-check:

Image Similarity
Expand Down
35 changes: 28 additions & 7 deletions docs/plugins/fetchart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ file. The available options are:
``cover front art album folder``.
- **fallback**: Path to a fallback album art file if no album art was found
otherwise. Default: ``None`` (disabled).
- **fetch_for_asis**: Fetch album art from online sources during import, even
when the as-is option is selected in the Auto-Tagger. When ``no``, only local
filesystem sources of art are considered for as-is imports. Default: ``no``.
- **minwidth**: Only images with a width bigger or equal to ``minwidth`` are
considered as valid album art candidates. Default: 0.
- **maxwidth**: A maximum image width to downscale fetched images if they are
Expand Down Expand Up @@ -65,13 +68,16 @@ file. The available options are:
of the longer edge (``enforce_ratio: 0.5%``). Default: ``no``.
- **sources**: List of sources to search for images. An asterisk ``*`` expands
to all available sources. Default: ``filesystem coverart itunes amazon
albumart``, i.e., everything but ``wikipedia``, ``google``, ``fanarttv`` and
``lastfm``. Enable those sources for more matches at the cost of some speed.
They are searched in the given order, thus in the default config, no remote
(Web) art source are queried if local art is found in the filesystem. To use a
local image as fallback, move it to the end of the list. For even more
fine-grained control over the search order, see the section on
:ref:`album-art-sources` below.
albumart``, i.e., everything but :ref:`embedded <embedded-art-source>`,
``wikipedia``, ``google``, ``fanarttv`` and ``lastfm``. Enable those sources
for more matches at the cost of some speed. They are searched in the given
order, thus in the default config, no remote (Web) art source are queried if
local art is found in the filesystem. To use a local image as fallback, move
it to the end of the list. For even more fine-grained control over the search
order, see the section on :ref:`album-art-sources` below.
- **skip_embedded**: Don't fetch art for files that already have embedded
artwork. Enabling both ``skip_embedded`` and the ``embedded`` source
effectively cancel each other out. Default: ``no``
- **google_key**: Your Google API key (to enable the Google Custom Search
backend). Default: None.
- **google_engine**: The custom search engine to use. Default: The `beets custom
Expand Down Expand Up @@ -214,6 +220,21 @@ When you choose to apply changes during an import, beets will search for art as
described above. For "as-is" imports (and non-autotagged imports using the
``-A`` flag), beets only looks for art on the local filesystem.

.. _embedded-art-source:

Embedded Art
~~~~~~~~~~~~

Enable the ``embedded`` source to extract embedded art into the album's
directory, to the file named by the :ref:`art-filename` config option. Placing
``embedded`` at the beginning of the ``sources`` list will prevent fetching from
other art sources if the album's files already have embedded art.

To skip fetching art for files that have embedded art, enable the
``skip_embedded`` configuration option.

See also: The :doc:`/plugins/embedart` for embedding fetched art into files.

Google custom search
~~~~~~~~~~~~~~~~~~~~

Expand Down
32 changes: 32 additions & 0 deletions test/plugins/test_art.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,38 @@ def test_is_candidate_fallback_os_error(
assert not result


class TestEmbeddedArt(UseThePlugin):
def setup_beets(self):
super().setup_beets()
self.albums = [
self.add_album_fixture(fname="image", ext=ext)
for ext in ["ape", "flac", "m4a", "mp3", "ogg", "wma"]
]

@pytest.fixture
def settings(self) -> Settings:
return Settings(fallback=None)

@pytest.fixture
def source(self) -> fetchart.Embedded:
return fetchart.Embedded(logger, self.plugin.config)

def test_default_skip_extract(self, source, settings):
for album in self.albums:
candidates = list(source.get(album, settings, None))
assert not candidates

def test_extract_embedded_art(self, source, settings):
self.plugin.config["extract_embedded"].set(True)

for album in self.albums:
candidate = next(source.get(album, settings, None))
assert candidate
# image.* fixtures have embedded PNG data
assert os.path.splitext(candidate.path)[1] == b".png"
assert Path(os.fsdecode(candidate.path)).exists()


class TestCombined(UseThePlugin, FetchImageHelper, CAAData):
ASIN = "xxxx"
MBID = "releaseid"
Expand Down
Loading