mirror of
https://github.com/beetbox/beets.git
synced 2026-01-08 08:56:56 +01:00
mbsync / missing plugins: consider all metadata backends (#5636)
This PR adds support for alternate metadata backends in the `mbsync` and `missing` plugins, allowing them to work with any metadata backend the items are tagged with instead of being limited to just MusicBrainz. Key changes: - Removed redundant `albums_for_id` and `items_for_id` methods. - Updated `album_for_id` and `track_for_id` implementations to return a single album/track as their names suggest. - Updated both plugins to use the above functions instead of MusicBrainz-specific ones. - Updated `missing` plugin docs to make it clear that missing albums for artists can only be obtained for the `musicbrainz` metadata backend - This is possible with other backends but implementation was outside of this PR's scope. - Slightly simplified the existing implementation The changes maintain backwards compatibility while enabling users to leverage metadata from sources like Deezer, Discogs or Bandcamp when syncing their libraries. **Note**: this change came about because I'm working on making `musicbrainz` a separate plugin: this will be submitted in the next PR.
This commit is contained in:
commit
d447bc3f46
12 changed files with 160 additions and 226 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue