-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Add Embedded Art support to fetchart plugin
#6829
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
7865930
70e4d46
4930f77
62b8528
58b7c38
012b241
1bfaee4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -160,7 +160,6 @@ def extract(log, outpath, item): | |
| art = get_art(log, item) | ||
| outpath = bytestring_path(outpath) | ||
| if not art: | ||
| log.info("No album art present in {}, skipping.", item) | ||
| return None | ||
|
|
||
| # Add an extension to the filename. | ||
|
|
@@ -170,9 +169,6 @@ def extract(log, outpath, item): | |
| return None | ||
| outpath += bytestring_path(f".{ext}") | ||
|
|
||
| log.info( | ||
| "Extracting album art from: {} to: {}", item, displayable_path(outpath) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| ) | ||
| with open(syspath(outpath), "wb") as f: | ||
| f.write(art) | ||
| return outpath | ||
|
|
@@ -182,7 +178,13 @@ def extract_first(log, outpath, items): | |
| for item in items: | ||
| real_path = extract(log, outpath, item) | ||
| if real_path: | ||
| log.info( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| "Extracting album art from: {} to: {}", | ||
| item, | ||
| displayable_path(real_path), | ||
| ) | ||
| return real_path | ||
| log.info("No album art present in {}, skipping.", item) | ||
| return None | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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(): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why was this renamed from
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| # 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( | ||
|
|
@@ -1285,9 +1286,50 @@ 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: Sequence[bytes] | None, | ||
| ) -> Iterator[Candidate]: | ||
| filename = get_temp_filename(__name__) | ||
| for item in album.items(): | ||
| if extracted_path := art.extract(self._log, filename, item): | ||
| self._log.debug("embedded: extracting art from {}", 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, | ||
|
|
@@ -1319,6 +1361,7 @@ def __init__(self) -> None: | |
| { | ||
| "auto": True, | ||
| "fetch_for_asis": False, | ||
| "skip_embedded": False, | ||
| "minwidth": 0, | ||
| "maxwidth": 0, | ||
| "quality": 0, | ||
|
|
@@ -1443,6 +1486,10 @@ def _get_sources(self) -> list[tuple[str, str]]: | |
| 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"][ | ||
|
|
@@ -1463,9 +1510,7 @@ 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: | ||
|
|
@@ -1554,6 +1599,17 @@ def func(lib: Library, opts, args) -> None: | |
| cmd.func = func | ||
| return [cmd] | ||
|
|
||
| def album_has_art(self, album: Album) -> bool: | ||
| 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( | ||
|
|
@@ -1607,11 +1663,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}") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -337,6 +337,31 @@ 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_extract_embedded_art(self, source, settings): | ||
| for album in self.albums: | ||
| candidate = next(source.get(album, settings, None)) | ||
|
Comment on lines
+357
to
+358
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to check every album? Would it be enough to get the first candidate instead?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This does get the first candidate for each album ( |
||
| 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" | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -5,6 +5,8 @@ | |||||
| from typing import TYPE_CHECKING, Any, ClassVar | ||||||
| from unittest import mock | ||||||
|
|
||||||
| import pytest | ||||||
|
|
||||||
| from beets import importer | ||||||
| from beets.test.helper import ( | ||||||
| AutotagImportHelper, | ||||||
|
|
@@ -18,6 +20,16 @@ | |||||
| from pathlib import Path | ||||||
|
|
||||||
|
|
||||||
| @pytest.fixture(autouse=True) | ||||||
| def disable_http_requests(requests_mock): | ||||||
| """Disable all outgoing requests and raise an error on HTTP | ||||||
|
|
||||||
| For test safety to ensure no changes cause tests to make | ||||||
| real, unmocked requests for cover art. | ||||||
| """ | ||||||
| return | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we actually want to make real requests here?
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is no setup or teardown in this fixture, so We don't want to make make real requests in |
||||||
|
|
||||||
|
|
||||||
| class TestFetchartImport(PluginMixin, AutotagImportHelper): | ||||||
| plugin = "fetchart" | ||||||
| preload_plugin = False | ||||||
|
|
@@ -67,6 +79,53 @@ def test_fetch_for_asis_uses_network_sources(self): | |||||
| self.remote_art_mock.assert_called() | ||||||
|
|
||||||
|
|
||||||
| class TestFetchartEmbeddedImport(PluginMixin, AutotagImportHelper): | ||||||
| plugin = "fetchart" | ||||||
| preload_plugin = False | ||||||
|
|
||||||
| def setup_beets(self): | ||||||
| super().setup_beets() | ||||||
|
|
||||||
| # Use track data fixture with embedded art | ||||||
| self.resource_path = self.resource_path.with_name("image.mp3") | ||||||
|
|
||||||
| self.prepare_album_for_import(1) | ||||||
| self.setup_importer() | ||||||
|
|
||||||
| @mock.patch.object( | ||||||
| FetchArtPlugin, | ||||||
| "art_for_album", | ||||||
| autospec=True, | ||||||
| wraps=FetchArtPlugin.art_for_album, | ||||||
| ) | ||||||
| def test_embedded_art_skips_fetch(self, art_for_album_mock): | ||||||
| self.importer.add_choice(importer.Action.APPLY) | ||||||
| with self.configure_plugin({"skip_embedded": True}): | ||||||
| self.importer.run() | ||||||
| art_for_album_mock.assert_not_called() | ||||||
| imported_album = self.lib.albums().get() | ||||||
| assert imported_album | ||||||
| assert not imported_album.art_filepath | ||||||
|
|
||||||
| def test_embedded_art_extracted(self): | ||||||
| self.importer.add_choice(importer.Action.APPLY) | ||||||
| with self.configure_plugin({"sources": ["embedded"]}): | ||||||
| self.importer.run() | ||||||
| imported_album = self.lib.albums().get() | ||||||
| assert imported_album | ||||||
| assert imported_album.art_filepath | ||||||
| assert imported_album.art_filepath.exists() | ||||||
|
|
||||||
| def test_embedded_art_extracted_when_asis(self): | ||||||
| self.importer.add_choice(importer.Action.ASIS) | ||||||
| with self.configure_plugin({"sources": ["embedded"]}): | ||||||
| self.importer.run() | ||||||
| imported_album = self.lib.albums().get() | ||||||
| assert imported_album | ||||||
| assert imported_album.art_filepath | ||||||
| assert imported_album.art_filepath.exists() | ||||||
|
|
||||||
|
|
||||||
| class TestFetchartCli(IOMixin, PluginTestHelper): | ||||||
| plugin = "fetchart" | ||||||
|
|
||||||
|
|
@@ -186,7 +245,7 @@ def test_sources_is_a_string(self): | |||||
| def test_sources_is_an_asterisk(self): | ||||||
| self.config["fetchart"].set({"sources": "*"}) | ||||||
| fa = FetchArtPlugin() | ||||||
| assert len(fa.sources) == 10 | ||||||
| assert len(fa.sources) == 11 | ||||||
|
|
||||||
| def test_sources_is_a_string_list(self): | ||||||
| self.config["fetchart"].set({"sources": ["filesystem", "coverart"]}) | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any specific reason for removing the logs? Lets keep the git diff minimal.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From aac06ee:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, did not check the commits. Nonetheless, please revert the changes here again. I get that it is currently spamy but the change is not in scope for this PR. We can decrease the level to debug if you have a strong opinion but we should not remove it completely.
We have planned to increase the default log level to
Warningwith #6773 or a following PR so this will likely be a none issue or can be evaluated again afterwards.