Merge branch 'master' into typehints-plugins

This commit is contained in:
Sebastian Mohr 2025-04-15 11:52:08 +02:00 committed by GitHub
commit f878e4da3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 242 additions and 256 deletions

View file

@ -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):

View file

@ -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 (

View file

@ -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.

View file

@ -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():

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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]

View file

@ -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)
-------------------------

View file

@ -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.

View file

@ -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
-------------

View file

@ -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

View file

@ -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

View file

@ -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 (

View file

@ -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):