diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 3fa80c6f3..81cfd7bb2 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -602,8 +602,7 @@ def album_for_mbid(release_id: str) -> AlbumInfo | None: if the ID is not found. """ try: - album = mb.album_for_id(release_id) - if album: + if album := mb.album_for_id(release_id): plugins.send("albuminfo_received", info=album) return album except mb.MusicBrainzAPIError as exc: @@ -616,8 +615,7 @@ def track_for_mbid(recording_id: str) -> TrackInfo | None: if the ID is not found. """ try: - track = mb.track_for_id(recording_id) - if track: + if track := mb.track_for_id(recording_id): plugins.send("trackinfo_received", info=track) return track except mb.MusicBrainzAPIError as exc: @@ -625,26 +623,14 @@ def track_for_mbid(recording_id: str) -> TrackInfo | None: return None -def albums_for_id(album_id: str) -> Iterable[AlbumInfo]: - """Get a list of albums for an ID.""" - a = album_for_mbid(album_id) - if a: - yield a - for a in plugins.album_for_id(album_id): - if a: - plugins.send("albuminfo_received", info=a) - yield a +def album_for_id(_id: str) -> AlbumInfo | None: + """Get AlbumInfo object for the given ID string.""" + return album_for_mbid(_id) or plugins.album_for_id(_id) -def tracks_for_id(track_id: str) -> Iterable[TrackInfo]: - """Get a list of tracks for an ID.""" - t = track_for_mbid(track_id) - if t: - yield t - for t in plugins.track_for_id(track_id): - if t: - plugins.send("trackinfo_received", info=t) - yield t +def track_for_id(_id: str) -> TrackInfo | None: + """Get TrackInfo object for the given ID string.""" + return track_for_mbid(_id) or plugins.track_for_id(_id) def invoke_mb(call_func: Callable, *args): diff --git a/beets/autotag/match.py b/beets/autotag/match.py index a7121fd34..bc30ccea2 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -510,8 +510,8 @@ def tag_album( if search_ids: for search_id in search_ids: log.debug("Searching for album ID: {0}", search_id) - for album_info_for_id in hooks.albums_for_id(search_id): - _add_candidate(items, candidates, album_info_for_id) + if info := hooks.album_for_id(search_id): + _add_candidate(items, candidates, info) # Use existing metadata or text search. else: @@ -590,11 +590,9 @@ def tag_item( if trackids: for trackid in trackids: log.debug("Searching for track ID: {0}", trackid) - for track_info in hooks.tracks_for_id(trackid): - dist = track_distance(item, track_info, incl_artist=True) - candidates[track_info.track_id] = hooks.TrackMatch( - dist, track_info - ) + if info := hooks.track_for_id(trackid): + dist = track_distance(item, info, incl_artist=True) + candidates[info.track_id] = hooks.TrackMatch(dist, info) # If this is a good match, then don't keep searching. rec = _recommendation(_sort_candidates(candidates.values())) if ( diff --git a/beets/plugins.py b/beets/plugins.py index 299c41815..2ca98649e 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -14,18 +14,25 @@ """Support for beets plugins.""" +from __future__ import annotations + import abc import inspect import re import traceback from collections import defaultdict from functools import wraps +from typing import TYPE_CHECKING import mediafile import beets from beets import logging +if TYPE_CHECKING: + from beets.autotag.hooks import AlbumInfo, TrackInfo + + PLUGIN_NAMESPACE = "beetsplug" # Plugins using the Last.fm API can share the same API key. @@ -290,7 +297,7 @@ def load_plugins(names=()): ) -_instances = {} +_instances: dict[type[BeetsPlugin], BeetsPlugin] = {} def find_plugins(): @@ -397,20 +404,30 @@ def item_candidates(item, artist, title): yield from plugin.item_candidates(item, artist, title) -def album_for_id(album_id): - """Get AlbumInfo objects for a given ID string.""" +def album_for_id(_id: str) -> AlbumInfo | None: + """Get AlbumInfo object for the given ID string. + + A single ID can yield just a single album, so we return the first match. + """ for plugin in find_plugins(): - album = plugin.album_for_id(album_id) - if album: - yield album + if info := plugin.album_for_id(_id): + send("albuminfo_received", info=info) + return info + + return None -def track_for_id(track_id): - """Get TrackInfo objects for a given ID string.""" +def track_for_id(_id: str) -> TrackInfo | None: + """Get TrackInfo object for the given ID string. + + A single ID can yield just a single track, so we return the first match. + """ for plugin in find_plugins(): - track = plugin.track_for_id(track_id) - if track: - yield track + if info := plugin.track_for_id(_id): + send("trackinfo_received", info=info) + return info + + return None def template_funcs(): diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 6f05474b9..25815e8d3 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -71,7 +71,7 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin): self._log.error("Error fetching data from {}\n Error: {}", url, e) return None if "error" in data: - self._log.error("Deezer API error: {}", data["error"]["message"]) + self._log.debug("Deezer API error: {}", data["error"]["message"]) return None return data diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 0dc8e8a17..19521b035 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -29,7 +29,6 @@ from string import ascii_lowercase import confuse from discogs_client import Client, Master, Release -from discogs_client import __version__ as dc_string from discogs_client.exceptions import DiscogsAPIError from requests.exceptions import ConnectionError from typing_extensions import TypedDict @@ -64,7 +63,6 @@ class ReleaseFormat(TypedDict): class DiscogsPlugin(BeetsPlugin): def __init__(self): super().__init__() - self.check_discogs_client() self.config.add( { "apikey": API_KEY, @@ -80,24 +78,7 @@ class DiscogsPlugin(BeetsPlugin): self.config["apikey"].redact = True self.config["apisecret"].redact = True self.config["user_token"].redact = True - self.discogs_client = None - self.register_listener("import_begin", self.setup) - - def check_discogs_client(self): - """Ensure python3-discogs-client version >= 2.3.15""" - dc_min_version = [2, 3, 15] - dc_version = [int(elem) for elem in dc_string.split(".")] - min_len = min(len(dc_version), len(dc_min_version)) - gt_min = [ - (elem > elem_min) - for elem, elem_min in zip( - dc_version[:min_len], dc_min_version[:min_len] - ) - ] - if True not in gt_min: - self._log.warning( - "python3-discogs-client version should be >= 2.3.15" - ) + self.setup() def setup(self, session=None): """Create the `discogs_client` field. Authenticate if necessary.""" @@ -179,13 +160,9 @@ class DiscogsPlugin(BeetsPlugin): """Returns a list of AlbumInfo objects for discogs search results matching an album and artist (if not various). """ - if not self.discogs_client: - return - if not album and not artist: self._log.debug( - "Skipping Discogs query. Files missing album and " - "artist tags." + "Skipping Discogs query. Files missing album and artist tags." ) return [] @@ -254,12 +231,9 @@ class DiscogsPlugin(BeetsPlugin): :return: Candidate TrackInfo objects. :rtype: list[beets.autotag.hooks.TrackInfo] """ - if not self.discogs_client: - return [] - if not artist and not title: self._log.debug( - "Skipping Discogs query. File missing artist and " "title tags." + "Skipping Discogs query. File missing artist and title tags." ) return [] @@ -290,9 +264,6 @@ class DiscogsPlugin(BeetsPlugin): """Fetches an album by its Discogs ID and returns an AlbumInfo object or None if the album is not found. """ - if not self.discogs_client: - return - self._log.debug("Searching for release {0}", album_id) discogs_id = extract_discogs_id_regex(album_id) diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index 283c40186..2e62b7b7e 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -12,17 +12,14 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""Update library's tags using MusicBrainz.""" +"""Synchronise library metadata with metadata source backends.""" -import re from collections import defaultdict from beets import autotag, library, ui, util from beets.autotag import hooks from beets.plugins import BeetsPlugin, apply_item_changes -MBID_REGEX = r"(\d|\w){8}-(\d|\w){4}-(\d|\w){4}-(\d|\w){4}-(\d|\w){12}" - class MBSyncPlugin(BeetsPlugin): def __init__(self): @@ -77,28 +74,15 @@ class MBSyncPlugin(BeetsPlugin): query. """ for item in lib.items(query + ["singleton:true"]): - item_formatted = format(item) if not item.mb_trackid: self._log.info( - "Skipping singleton with no mb_trackid: {0}", item_formatted + "Skipping singleton with no mb_trackid: {}", item ) continue - # Do we have a valid MusicBrainz track ID? - if not re.match(MBID_REGEX, item.mb_trackid): + if not (track_info := hooks.track_for_id(item.mb_trackid)): self._log.info( - "Skipping singleton with invalid mb_trackid:" + " {0}", - item_formatted, - ) - continue - - # Get the MusicBrainz recording info. - track_info = hooks.track_for_mbid(item.mb_trackid) - if not track_info: - self._log.info( - "Recording ID not found: {0} for track {0}", - item.mb_trackid, - item_formatted, + "Recording ID not found: {0.mb_trackid} for track {0}", item ) continue @@ -112,31 +96,14 @@ class MBSyncPlugin(BeetsPlugin): query and their items. """ # Process matching albums. - for a in lib.albums(query): - album_formatted = format(a) - if not a.mb_albumid: - self._log.info( - "Skipping album with no mb_albumid: {0}", album_formatted - ) + for album in lib.albums(query): + if not album.mb_albumid: + self._log.info("Skipping album with no mb_albumid: {}", album) continue - items = list(a.items()) - - # Do we have a valid MusicBrainz album ID? - if not re.match(MBID_REGEX, a.mb_albumid): + if not (album_info := hooks.album_for_id(album.mb_albumid)): self._log.info( - "Skipping album with invalid mb_albumid: {0}", - album_formatted, - ) - continue - - # Get the MusicBrainz album information. - album_info = hooks.album_for_mbid(a.mb_albumid) - if not album_info: - self._log.info( - "Release ID {0} not found for album {1}", - a.mb_albumid, - album_formatted, + "Release ID {0.mb_albumid} not found for album {0}", album ) continue @@ -153,6 +120,7 @@ class MBSyncPlugin(BeetsPlugin): # first, if available, and recording MBIDs otherwise). This should # work for albums that have missing or extra tracks. mapping = {} + items = list(album.items()) for item in items: if ( item.mb_releasetrackid @@ -175,11 +143,11 @@ class MBSyncPlugin(BeetsPlugin): break # Apply. - self._log.debug("applying changes to {}", album_formatted) + self._log.debug("applying changes to {}", album) with lib.transaction(): autotag.apply_metadata(album_info, mapping) changed = False - # Find any changed item to apply MusicBrainz changes to album. + # Find any changed item to apply changes to album. any_changed_item = items[0] for item in items: item_changed = ui.show_model_changes(item) @@ -195,10 +163,10 @@ class MBSyncPlugin(BeetsPlugin): if not pretend: # Update album structure to reflect an item in it. for key in library.Album.item_keys: - a[key] = any_changed_item[key] - a.store() + album[key] = any_changed_item[key] + album.store() # Move album art (and any inconsistent items). if move and lib.directory in util.ancestry(items[0].path): - self._log.debug("moving album {0}", album_formatted) - a.move() + self._log.debug("moving album {}", album) + album.move() diff --git a/beetsplug/missing.py b/beetsplug/missing.py index d5e4deda1..ccaa65320 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -16,6 +16,7 @@ """List missing tracks.""" from collections import defaultdict +from collections.abc import Iterator import musicbrainzngs from musicbrainzngs.musicbrainz import MusicBrainzError @@ -23,10 +24,12 @@ from musicbrainzngs.musicbrainz import MusicBrainzError from beets import config from beets.autotag import hooks from beets.dbcore import types -from beets.library import Item +from beets.library import Album, Item, Library from beets.plugins import BeetsPlugin from beets.ui import Subcommand, decargs, print_ +MB_ARTIST_QUERY = r"mb_albumartistid::^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$" + def _missing_count(album): """Return number of missing items in `album`.""" @@ -165,80 +168,67 @@ class MissingPlugin(BeetsPlugin): for item in self._missing(album): print_(format(item, fmt)) - def _missing_albums(self, lib, query): + def _missing_albums(self, lib: Library, query: list[str]) -> None: """Print a listing of albums missing from each artist in the library matching query. """ - total = self.config["total"].get() + query.append(MB_ARTIST_QUERY) - albums = lib.albums(query) - # build dict mapping artist to list of their albums in library - albums_by_artist = defaultdict(list) - for alb in albums: - artist = (alb["albumartist"], alb["mb_albumartistid"]) - albums_by_artist[artist].append(alb) + # build dict mapping artist to set of their album ids in library + album_ids_by_artist = defaultdict(set) + for album in lib.albums(query): + # TODO(@snejus): Some releases have different `albumartist` for the + # same `mb_albumartistid`. Since we're grouping by the combination + # of these two fields, we end up processing the same + # `mb_albumartistid` multiple times: calling MusicBrainz API and + # reporting the same set of missing albums. Instead, we should + # group by `mb_albumartistid` field only. + artist = (album["albumartist"], album["mb_albumartistid"]) + album_ids_by_artist[artist].add(album) total_missing = 0 - - # build dict mapping artist to list of all albums - for artist, albums in albums_by_artist.items(): - if artist[1] is None or artist[1] == "": - albs_no_mbid = ["'" + a["album"] + "'" for a in albums] - self._log.info( - "No musicbrainz ID for artist '{}' found in album(s) {}; " - "skipping", - artist[0], - ", ".join(albs_no_mbid), - ) - continue - + calculating_total = self.config["total"].get() + for (artist, artist_id), album_ids in album_ids_by_artist.items(): try: - resp = musicbrainzngs.browse_release_groups(artist=artist[1]) - release_groups = resp["release-group-list"] + resp = musicbrainzngs.browse_release_groups(artist=artist_id) except MusicBrainzError as err: self._log.info( "Couldn't fetch info for artist '{}' ({}) - '{}'", - artist[0], - artist[1], + artist, + artist_id, err, ) continue - missing = [] - present = [] - for rg in release_groups: - missing.append(rg) - for alb in albums: - if alb["mb_releasegroupid"] == rg["id"]: - missing.remove(rg) - present.append(rg) - break + missing_titles = [ + f"{artist} - {rg['title']}" + for rg in resp["release-group-list"] + if rg["id"] not in album_ids + ] - total_missing += len(missing) - if total: - continue + if calculating_total: + total_missing += len(missing_titles) + else: + for title in missing_titles: + print(title) - missing_titles = {rg["title"] for rg in missing} - - for release_title in missing_titles: - print_("{} - {}".format(artist[0], release_title)) - - if total: + if calculating_total: print(total_missing) - def _missing(self, album): + def _missing(self, album: Album) -> Iterator[Item]: """Query MusicBrainz to determine items missing from `album`.""" - item_mbids = [x.mb_trackid for x in album.items()] - if len(list(album.items())) < album.albumtotal: - # fetch missing items - # TODO: Implement caching that without breaking other stuff - album_info = hooks.album_for_mbid(album.mb_albumid) - for track_info in getattr(album_info, "tracks", []): + if len(album.items()) == album.albumtotal: + return + + item_mbids = {x.mb_trackid for x in album.items()} + # fetch missing items + # TODO: Implement caching that without breaking other stuff + if album_info := hooks.album_for_id(album.mb_albumid): + for track_info in album_info.tracks: if track_info.track_id not in item_mbids: - item = _item(track_info, album_info, album.id) self._log.debug( "track {0} in album {1}", track_info.track_id, album_info.album_id, ) - yield item + yield _item(track_info, album_info, album.id) diff --git a/docs/changelog.rst b/docs/changelog.rst index 183fa006e..32bd91390 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -21,6 +21,8 @@ New features: when fetching lyrics. * :doc:`plugins/lyrics`: Rewrite lyrics translation functionality to use Azure AI Translator API and add relevant instructions to the documentation. +* :doc:`plugins/missing`: Add support for all metadata sources. +* :doc:`plugins/mbsync`: Add support for all metadata sorces. Bug fixes: diff --git a/docs/plugins/mbsync.rst b/docs/plugins/mbsync.rst index 1c8663dca..647ff4df8 100644 --- a/docs/plugins/mbsync.rst +++ b/docs/plugins/mbsync.rst @@ -1,14 +1,13 @@ MBSync Plugin ============= -This plugin provides the ``mbsync`` command, which lets you fetch metadata -from MusicBrainz for albums and tracks that already have MusicBrainz IDs. This -is useful for updating tags as they are fixed in the MusicBrainz database, or -when you change your mind about some config options that change how tags are -written to files. If you have a music library that is already nicely tagged by -a program that also uses MusicBrainz like Picard, this can speed up the -initial import if you just import "as-is" and then use ``mbsync`` to get -up-to-date tags that are written to the files according to your beets +This plugin provides the ``mbsync`` command, which lets you synchronize +metadata for albums and tracks that have external data source IDs. + +This is useful for syncing your library with online data or when changing +configuration options that affect tag writing. If your music library already +contains correct tags, you can speed up the initial import by importing files +"as-is" and then using ``mbsync`` to write tags according to your beets configuration. diff --git a/docs/plugins/missing.rst b/docs/plugins/missing.rst index 38a0d5a08..9cd3fde71 100644 --- a/docs/plugins/missing.rst +++ b/docs/plugins/missing.rst @@ -1,31 +1,35 @@ Missing Plugin ============== -This plugin adds a new command, ``missing`` or ``miss``, which finds -and lists, for every album in your collection, which or how many -tracks are missing. Listing missing files requires one network call to -MusicBrainz. Merely counting missing files avoids any network calls. +This plugin adds a new command, ``missing`` or ``miss``, which finds and lists +missing tracks for albums in your collection. Each album requires one network +call to album data source. Usage ----- Add the ``missing`` plugin to your configuration (see :ref:`using-plugins`). -By default, the ``beet missing`` command lists the names of tracks that your -library is missing from each album. It can also list the names of albums that -your library is missing from each artist. -You can customize the output format, count -the number of missing tracks per album, or total up the number of missing -tracks over your whole library, using command-line switches:: +The ``beet missing`` command fetches album information from the origin data +source and lists names of the **tracks** that are missing from your library. + +It can also list the names of missing **albums** for each artist, although this +is limited to albums from the MusicBrainz data source only. + +You can customize the output format, show missing counts instead of track +titles, or display the total number of missing entities across your entire +library:: -f FORMAT, --format=FORMAT print with custom FORMAT -c, --count count missing tracks per album - -t, --total count total of missing tracks or albums - -a, --album show missing albums for artist instead of tracks + -t, --total count totals across the entire library + -a, --album show missing albums for artist instead of tracks for album -…or by editing corresponding options. +…or by editing the corresponding configuration options. -Note that ``-c`` is ignored when used with ``-a``. +.. warning:: + + Option ``-c`` is ignored when used with ``-a``. Configuration ------------- diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 8a4609e25..5e327ab27 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -14,6 +14,8 @@ """Tests for discogs plugin.""" +from unittest.mock import Mock, patch + import pytest from beets import config @@ -23,6 +25,7 @@ from beets.util.id_extractors import extract_discogs_id_regex from beetsplug.discogs import DiscogsPlugin +@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) class DGAlbumInfoTest(BeetsTestCase): def _make_release(self, tracks=None): """Returns a Bag that mimics a discogs_client.Release. The list diff --git a/test/plugins/test_mbsync.py b/test/plugins/test_mbsync.py index f65df4256..088165ef5 100644 --- a/test/plugins/test_mbsync.py +++ b/test/plugins/test_mbsync.py @@ -12,7 +12,7 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -from unittest.mock import patch +from unittest.mock import Mock, patch from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.library import Item @@ -22,37 +22,36 @@ from beets.test.helper import PluginTestCase, capture_log class MbsyncCliTest(PluginTestCase): plugin = "mbsync" - @patch("beets.autotag.mb.album_for_id") - @patch("beets.autotag.mb.track_for_id") - def test_update_library(self, track_for_id, album_for_id): + @patch( + "beets.plugins.album_for_id", + Mock( + side_effect=lambda *_: AlbumInfo( + album_id="album id", + album="new album", + tracks=[TrackInfo(track_id="track id", title="new title")], + ) + ), + ) + @patch( + "beets.plugins.track_for_id", + Mock( + side_effect=lambda *_: TrackInfo( + track_id="singleton id", title="new title" + ) + ), + ) + def test_update_library(self): album_item = Item( album="old album", - mb_albumid="81ae60d4-5b75-38df-903a-db2cfa51c2c6", + mb_albumid="album id", mb_trackid="track id", ) self.lib.add_album([album_item]) - singleton = Item( - title="old title", mb_trackid="b8c2cf90-83f9-3b5f-8ccd-31fb866fcf37" - ) + singleton = Item(title="old title", mb_trackid="singleton id") self.lib.add(singleton) - album_for_id.return_value = AlbumInfo( - album_id="album id", - album="new album", - tracks=[ - TrackInfo(track_id=album_item.mb_trackid, title="new title") - ], - ) - track_for_id.return_value = TrackInfo( - track_id=singleton.mb_trackid, title="new title" - ) - - with capture_log() as logs: - self.run_command("mbsync") - - assert "Sending event: albuminfo_received" in logs - assert "Sending event: trackinfo_received" in logs + self.run_command("mbsync") singleton.load() assert singleton.title == "new title" @@ -81,9 +80,6 @@ class MbsyncCliTest(PluginTestCase): with capture_log("beets.mbsync") as logs: self.run_command("mbsync", "-f", "'%if{$album,$album,$title}'") - assert set(logs) == { - "mbsync: Skipping album with no mb_albumid: 'no id'", - "mbsync: Skipping album with invalid mb_albumid: 'invalid id'", - "mbsync: Skipping singleton with no mb_trackid: 'no id'", - "mbsync: Skipping singleton with invalid mb_trackid: 'invalid id'", - } + + assert "mbsync: Skipping album with no mb_albumid: 'no id'" in logs + assert "mbsync: Skipping singleton with no mb_trackid: 'no id'" in logs