mirror of
https://github.com/beetbox/beets.git
synced 2025-12-25 10:05:13 +01:00
Merge branch 'master' into typehints-plugins
This commit is contained in:
commit
f878e4da3e
17 changed files with 242 additions and 256 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 (
|
||||
|
|
|
|||
|
|
@ -716,6 +716,15 @@ class Model(ABC, Generic[D]):
|
|||
"""Set the object's key to a value represented by a string."""
|
||||
self[key] = self._parse(key, string)
|
||||
|
||||
def __getstate__(self):
|
||||
"""Return the state of the object for pickling.
|
||||
Remove the database connection as sqlite connections are not
|
||||
picklable.
|
||||
"""
|
||||
state = self.__dict__.copy()
|
||||
state["_db"] = None
|
||||
return state
|
||||
|
||||
|
||||
# Database controller and supporting interfaces.
|
||||
|
||||
|
|
|
|||
|
|
@ -350,7 +350,8 @@ def load_plugins(names: Sequence[str] = ()):
|
|||
)
|
||||
|
||||
|
||||
_instances: dict[Type[BeetsPlugin], BeetsPlugin] = {}
|
||||
|
||||
_instances: dict[type[BeetsPlugin], BeetsPlugin] = {}
|
||||
|
||||
|
||||
def find_plugins() -> list[BeetsPlugin]:
|
||||
|
|
@ -467,20 +468,31 @@ def item_candidates(item: Item, artist: str, title: str) -> Iterable[TrackInfo]:
|
|||
yield from plugin.item_candidates(item, artist, title)
|
||||
|
||||
|
||||
def album_for_id(album_id: str) -> Iterable[AlbumInfo]:
|
||||
"""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: str) -> Iterable[TrackInfo]:
|
||||
"""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)
|
||||
|
|
|
|||
|
|
@ -14,13 +14,20 @@
|
|||
|
||||
"""Moves "featured" artists to the title from the artist field."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from beets import plugins, ui
|
||||
from beets.util import displayable_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beets.importer import ImportSession, ImportTask
|
||||
from beets.library import Item
|
||||
|
||||
def split_on_feat(artist):
|
||||
|
||||
def split_on_feat(artist: str) -> tuple[str, str | None]:
|
||||
"""Given an artist string, split the "main" artist from any artist
|
||||
on the right-hand side of a string like "feat". Return the main
|
||||
artist, which is always a string, and the featuring artist, which
|
||||
|
|
@ -28,14 +35,15 @@ def split_on_feat(artist):
|
|||
"""
|
||||
# split on the first "feat".
|
||||
regex = re.compile(plugins.feat_tokens(), re.IGNORECASE)
|
||||
parts = [s.strip() for s in regex.split(artist, 1)]
|
||||
parts = tuple(s.strip() for s in regex.split(artist, 1))
|
||||
if len(parts) == 1:
|
||||
return parts[0], None
|
||||
else:
|
||||
return tuple(parts)
|
||||
assert len(parts) == 2 # help mypy out
|
||||
return parts
|
||||
|
||||
|
||||
def contains_feat(title):
|
||||
def contains_feat(title: str) -> bool:
|
||||
"""Determine whether the title contains a "featured" marker."""
|
||||
return bool(
|
||||
re.search(
|
||||
|
|
@ -46,7 +54,7 @@ def contains_feat(title):
|
|||
)
|
||||
|
||||
|
||||
def find_feat_part(artist, albumartist):
|
||||
def find_feat_part(artist: str, albumartist: str) -> str | None:
|
||||
"""Attempt to find featured artists in the item's artist fields and
|
||||
return the results. Returns None if no featured artist found.
|
||||
"""
|
||||
|
|
@ -75,7 +83,7 @@ def find_feat_part(artist, albumartist):
|
|||
|
||||
|
||||
class FtInTitlePlugin(plugins.BeetsPlugin):
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.config.add(
|
||||
|
|
@ -103,7 +111,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
|||
if self.config["auto"]:
|
||||
self.import_stages = [self.imported]
|
||||
|
||||
def commands(self):
|
||||
def commands(self) -> list[ui.Subcommand]:
|
||||
def func(lib, opts, args):
|
||||
self.config.set_args(opts)
|
||||
drop_feat = self.config["drop"].get(bool)
|
||||
|
|
@ -111,24 +119,30 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
|||
write = ui.should_write()
|
||||
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
self.ft_in_title(item, drop_feat, keep_in_artist_field)
|
||||
item.store()
|
||||
if write:
|
||||
item.try_write()
|
||||
if self.ft_in_title(item, drop_feat, keep_in_artist_field):
|
||||
item.store()
|
||||
if write:
|
||||
item.try_write()
|
||||
|
||||
self._command.func = func
|
||||
return [self._command]
|
||||
|
||||
def imported(self, session, task):
|
||||
def imported(self, session: ImportSession, task: ImportTask) -> None:
|
||||
"""Import hook for moving featuring artist automatically."""
|
||||
drop_feat = self.config["drop"].get(bool)
|
||||
keep_in_artist_field = self.config["keep_in_artist"].get(bool)
|
||||
|
||||
for item in task.imported_items():
|
||||
self.ft_in_title(item, drop_feat, keep_in_artist_field)
|
||||
item.store()
|
||||
if self.ft_in_title(item, drop_feat, keep_in_artist_field):
|
||||
item.store()
|
||||
|
||||
def update_metadata(self, item, feat_part, drop_feat, keep_in_artist_field):
|
||||
def update_metadata(
|
||||
self,
|
||||
item: Item,
|
||||
feat_part: str,
|
||||
drop_feat: bool,
|
||||
keep_in_artist_field: bool,
|
||||
) -> None:
|
||||
"""Choose how to add new artists to the title and set the new
|
||||
metadata. Also, print out messages about any changes that are made.
|
||||
If `drop_feat` is set, then do not add the artist to the title; just
|
||||
|
|
@ -156,9 +170,17 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
|||
self._log.info("title: {0} -> {1}", item.title, new_title)
|
||||
item.title = new_title
|
||||
|
||||
def ft_in_title(self, item, drop_feat, keep_in_artist_field):
|
||||
def ft_in_title(
|
||||
self,
|
||||
item: Item,
|
||||
drop_feat: bool,
|
||||
keep_in_artist_field: bool,
|
||||
) -> bool:
|
||||
"""Look for featured artists in the item's artist fields and move
|
||||
them to the title.
|
||||
|
||||
Returns:
|
||||
True if the item has been modified. False otherwise.
|
||||
"""
|
||||
artist = item.artist.strip()
|
||||
albumartist = item.albumartist.strip()
|
||||
|
|
@ -166,19 +188,22 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
|||
# Check whether there is a featured artist on this track and the
|
||||
# artist field does not exactly match the album artist field. In
|
||||
# that case, we attempt to move the featured artist to the title.
|
||||
if not albumartist or albumartist == artist:
|
||||
return False
|
||||
|
||||
_, featured = split_on_feat(artist)
|
||||
if featured and albumartist != artist and albumartist:
|
||||
self._log.info("{}", displayable_path(item.path))
|
||||
if not featured:
|
||||
return False
|
||||
|
||||
feat_part = None
|
||||
self._log.info("{}", displayable_path(item.path))
|
||||
|
||||
# Attempt to find the featured artist.
|
||||
feat_part = find_feat_part(artist, albumartist)
|
||||
# Attempt to find the featured artist.
|
||||
feat_part = find_feat_part(artist, albumartist)
|
||||
|
||||
# If we have a featuring artist, move it to the title.
|
||||
if feat_part:
|
||||
self.update_metadata(
|
||||
item, feat_part, drop_feat, keep_in_artist_field
|
||||
)
|
||||
else:
|
||||
self._log.info("no featuring artists found")
|
||||
if not feat_part:
|
||||
self._log.info("no featuring artists found")
|
||||
return False
|
||||
|
||||
# If we have a featuring artist, move it to the title.
|
||||
self.update_metadata(item, feat_part, drop_feat, keep_in_artist_field)
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ class GioURI(URIGetter):
|
|||
if self.available:
|
||||
self.libgio.g_type_init() # for glib < 2.36
|
||||
|
||||
self.libgio.g_file_get_uri.argtypes = [ctypes.c_char_p]
|
||||
self.libgio.g_file_new_for_path.argtypes = [ctypes.c_char_p]
|
||||
self.libgio.g_file_new_for_path.restype = ctypes.c_void_p
|
||||
|
||||
self.libgio.g_file_get_uri.argtypes = [ctypes.c_void_p]
|
||||
|
|
|
|||
|
|
@ -21,9 +21,14 @@ 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:
|
||||
|
||||
* :doc:`plugins/thumbnails`: Fix API call to GIO on big endian architectures
|
||||
(like s390x) in thumbnails plugin.
|
||||
:bug:`5708`
|
||||
* :doc:`plugins/listenbrainz`: Fix rST formatting for URLs of Listenbrainz API Key documentation and config.yaml.
|
||||
* :doc:`plugins/listenbrainz`: Fix ``UnboundLocalError`` in cases where 'mbid' is not defined.
|
||||
* :doc:`plugins/fetchart`: Fix fetchart bug where a tempfile could not be deleted due to never being
|
||||
|
|
@ -95,6 +100,9 @@ Other changes:
|
|||
EXTM3U playlists instead of JSON-encoding them.
|
||||
* typehints: `./beets/importer.py` file now has improved typehints.
|
||||
* typehints: `./beets/plugins.py` file now includes typehints.
|
||||
* :doc:`plugins/ftintitle`: Optimize the plugin by avoiding unnecessary writes
|
||||
to the database.
|
||||
* Database models are now serializable with pickle.
|
||||
|
||||
2.2.0 (December 02, 2024)
|
||||
-------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -265,7 +265,10 @@ class ThumbnailsTest(BeetsTestCase):
|
|||
if not gio.available:
|
||||
self.skipTest("GIO library not found")
|
||||
|
||||
assert gio.uri("/foo") == "file:///" # silent fail
|
||||
import ctypes
|
||||
|
||||
with pytest.raises(ctypes.ArgumentError):
|
||||
gio.uri("/foo")
|
||||
assert gio.uri(b"/foo") == "file:///foo"
|
||||
assert gio.uri(b"/foo!") == "file:///foo!"
|
||||
assert (
|
||||
|
|
|
|||
|
|
@ -421,6 +421,20 @@ class ModelTest(unittest.TestCase):
|
|||
with pytest.raises(TypeError, match="must be a string"):
|
||||
dbcore.Model._parse(None, 42)
|
||||
|
||||
def test_pickle_dump(self):
|
||||
"""Tries to pickle an item. This tests the __getstate__ method
|
||||
of the Model ABC"""
|
||||
import pickle
|
||||
|
||||
model = ModelFixture1(self.db)
|
||||
model.add(self.db)
|
||||
model.field_one = 123
|
||||
|
||||
model.store()
|
||||
assert model._db is not None
|
||||
|
||||
pickle.dumps(model)
|
||||
|
||||
|
||||
class FormatTest(unittest.TestCase):
|
||||
def test_format_fixed_field_integer(self):
|
||||
|
|
|
|||
Loading…
Reference in a new issue