Merge branch 'beetbox:master' into arm-info

This commit is contained in:
RollingStar 2024-12-04 19:56:26 -05:00 committed by GitHub
commit eafae03560
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1027 additions and 906 deletions

View file

@ -1,6 +1,6 @@
name: Test
on:
pull_request_target:
pull_request:
push:
branches:
- master
@ -75,4 +75,4 @@ jobs:
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
use_oidc: true
use_oidc: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) }}

View file

@ -1,7 +1,7 @@
name: Lint check
run-name: Lint code
on:
pull_request_target:
pull_request:
push:
branches:
- master
@ -125,7 +125,7 @@ jobs:
cache: poetry
- name: Install dependencies
run: poetry install --only=docs
run: poetry install --extras=docs
- name: Add Sphinx problem matcher
run: echo "::add-matcher::.github/sphinx-problem-matcher.json"

View file

@ -40,8 +40,13 @@ jobs:
name: Get changelog and build the distribution package
runs-on: ubuntu-latest
needs: increment-version
outputs:
changelog: ${{ steps.generate_changelog.outputs.changelog }}
steps:
- uses: actions/checkout@v4
with:
ref: master
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v0.1.1
- uses: actions/setup-python@v5
@ -50,16 +55,22 @@ jobs:
cache: poetry
- name: Install dependencies
run: poetry install --only=release
run: poetry install --with=release --extras=docs
- name: Install pandoc
run: sudo apt update && sudo apt install pandoc -y
- name: Obtain the changelog
run: echo "changelog=$(poe changelog)" >> $GITHUB_OUTPUT
id: generate_changelog
run: |
{
echo 'changelog<<EOF'
poe changelog
echo EOF
} >> "$GITHUB_OUTPUT"
- name: Build a binary wheel and a source tarball
run: poetry build
run: poe build
- name: Store the distribution packages
uses: actions/upload-artifact@v4
@ -88,19 +99,23 @@ jobs:
make-github-release:
name: Create GitHub release
runs-on: ubuntu-latest
needs: publish-to-pypi
needs: [build, publish-to-pypi]
env:
CHANGELOG: ${{ needs.build.outputs.changelog }}
steps:
- uses: actions/checkout@v4
with:
ref: master
- name: Tag the commit
id: tag_version
uses: mathieudutour/github-tag-action@v6
uses: mathieudutour/github-tag-action@v6.2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
custom_tag: ${{ env.NEW_VERSION }}
- name: Download all the dists
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: python-package-distributions
path: dist/
@ -117,6 +132,7 @@ jobs:
artifacts: dist/*
- name: Send release toot to Fosstodon
uses: cbrgm/mastodon-github-action@v2
continue-on-error: true
with:
access-token: ${{ secrets.MASTODON_ACCESS_TOKEN }}
url: ${{ secrets.MASTODON_URL }}

View file

@ -3,6 +3,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.6
rev: v0.8.0
hooks:
- id: ruff-format

View file

@ -17,7 +17,7 @@ from sys import stderr
import confuse
__version__ = "2.0.0"
__version__ = "2.2.0"
__author__ = "Adrian Sampson <adrian@radbox.org>"

View file

@ -208,6 +208,10 @@ def track_distance(
if item.mb_trackid:
dist.add_expr("track_id", item.mb_trackid != track_info.track_id)
# Penalize mismatching disc numbers.
if track_info.medium and item.disc:
dist.add_expr("medium", item.disc != track_info.medium)
# Plugins.
dist.update(plugins.track_distance(item, track_info))

View file

@ -207,6 +207,7 @@ match:
track_index: 1.0
track_length: 2.0
track_id: 5.0
medium: 1.0
preferred:
countries: []
media: []

View file

@ -605,7 +605,7 @@ class ImportTask(BaseImportTask):
"""
items = self.imported_items()
for field, view in config["import"]["set_fields"].items():
value = view.get()
value = str(view.get())
log.debug(
"Set field {1}={2} for {0}",
displayable_path(self.paths),
@ -1062,7 +1062,7 @@ class SingletonImportTask(ImportTask):
values, for the singleton item.
"""
for field, view in config["import"]["set_fields"].items():
value = view.get()
value = str(view.get())
log.debug(
"Set field {1}={2} for {0}",
displayable_path(self.paths),

View file

@ -58,17 +58,12 @@ log = logging.getLogger("beets")
log.propagate = True
log.setLevel(logging.DEBUG)
# Dummy item creation.
_item_ident = 0
# OS feature test.
HAVE_SYMLINK = sys.platform != "win32"
HAVE_HARDLINK = sys.platform != "win32"
def item(lib=None):
global _item_ident
_item_ident += 1
i = beets.library.Item(
title="the title",
artist="the artist",
@ -93,7 +88,6 @@ def item(lib=None):
comments="the comments",
bpm=8,
comp=True,
path=f"somepath{_item_ident}",
length=60.0,
bitrate=128000,
format="FLAC",
@ -110,30 +104,6 @@ def item(lib=None):
return i
def album(lib=None):
global _item_ident
_item_ident += 1
i = beets.library.Album(
artpath=None,
albumartist="some album artist",
albumartist_sort="some sort album artist",
albumartist_credit="some album artist credit",
album="the album",
genre="the genre",
year=2014,
month=2,
day=5,
tracktotal=0,
disctotal=1,
comp=False,
mb_albumid="someID-1",
mb_albumartistid="someID-1",
)
if lib:
lib.add(i)
return i
# Dummy import session.
def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False):
cls = commands.TerminalImportSession if cli else importer.ImportSession

View file

@ -20,9 +20,6 @@ information or mock the environment.
- `has_program` checks the presence of a command on the system.
- The `generate_album_info` and `generate_track_info` functions return
fixtures to be used when mocking the autotagger.
- The `ImportSessionFixture` allows one to run importer code while
controlling the interactions through code.
@ -249,16 +246,15 @@ class TestHelper(_common.Assertions):
The item is attached to the database from `self.lib`.
"""
item_count = self._get_item_count()
values_ = {
"title": "t\u00eftle {0}",
"artist": "the \u00e4rtist",
"album": "the \u00e4lbum",
"track": item_count,
"track": 1,
"format": "MP3",
}
values_.update(values)
values_["title"] = values_["title"].format(item_count)
values_["title"] = values_["title"].format(1)
values_["db"] = self.lib
item = Item(**values_)
if "path" not in values:
@ -375,12 +371,6 @@ class TestHelper(_common.Assertions):
return path
def _get_item_count(self):
if not hasattr(self, "__item_count"):
count = 0
self.__item_count = count + 1
return count
# Running beets commands
def run_command(self, *args, **kwargs):
@ -513,12 +503,8 @@ class PluginMixin:
Album._queries = getattr(Album, "_original_queries", {})
@contextmanager
def configure_plugin(self, config: list[Any] | dict[str, Any]):
if isinstance(config, list):
beets.config[self.plugin] = config
else:
for key, value in config.items():
beets.config[self.plugin][key] = value
def configure_plugin(self, config: Any):
beets.config[self.plugin].set(config)
self.load_plugins(self.plugin)
yield
@ -723,10 +709,6 @@ class ImportSessionFixture(ImportSession):
default_resolution = "REMOVE"
def add_resolution(self, resolution):
assert isinstance(resolution, self.Resolution)
self._resolutions.append(resolution)
def resolve_duplicate(self, task, found_duplicates):
try:
res = self._resolutions.pop(0)
@ -779,12 +761,10 @@ class TerminalImportSessionFixture(TerminalImportSession):
self.io.addinput("T")
elif choice == importer.action.SKIP:
self.io.addinput("S")
elif isinstance(choice, int):
else:
self.io.addinput("M")
self.io.addinput(str(choice))
self._add_choice_input()
else:
raise Exception("Unknown choice %s" % choice)
class TerminalImportMixin(ImportHelper):
@ -803,82 +783,6 @@ class TerminalImportMixin(ImportHelper):
)
def generate_album_info(album_id, track_values):
"""Return `AlbumInfo` populated with mock data.
Sets the album info's `album_id` field is set to the corresponding
argument. For each pair (`id`, `values`) in `track_values` the `TrackInfo`
from `generate_track_info` is added to the album info's `tracks` field.
Most other fields of the album and track info are set to "album
info" and "track info", respectively.
"""
tracks = [generate_track_info(id, values) for id, values in track_values]
album = AlbumInfo(
album_id="album info",
album="album info",
artist="album info",
artist_id="album info",
tracks=tracks,
)
for field in ALBUM_INFO_FIELDS:
setattr(album, field, "album info")
return album
ALBUM_INFO_FIELDS = [
"album",
"album_id",
"artist",
"artist_id",
"asin",
"albumtype",
"va",
"label",
"barcode",
"artist_sort",
"releasegroup_id",
"catalognum",
"language",
"country",
"albumstatus",
"media",
"albumdisambig",
"releasegroupdisambig",
"artist_credit",
"data_source",
"data_url",
]
def generate_track_info(track_id="track info", values={}):
"""Return `TrackInfo` populated with mock data.
The `track_id` field is set to the corresponding argument. All other
string fields are set to "track info".
"""
track = TrackInfo(
title="track info",
track_id=track_id,
)
for field in TRACK_INFO_FIELDS:
setattr(track, field, "track info")
for field, value in values.items():
setattr(track, field, value)
return track
TRACK_INFO_FIELDS = [
"artist",
"artist_id",
"artist_sort",
"disctitle",
"artist_credit",
"data_source",
"data_url",
]
class AutotagStub:
"""Stub out MusicBrainz album and track matcher and control what the
autotagger returns.
@ -992,7 +896,7 @@ class FetchImageHelper:
super().run(*args, **kwargs)
IMAGEHEADER = {
"image/jpeg": b"\x00" * 6 + b"JFIF",
"image/jpeg": b"\xff\xd8\xff" + b"\x00" * 3 + b"JFIF",
"image/png": b"\211PNG\r\n\032\n",
}

View file

@ -1858,6 +1858,14 @@ def main(args=None):
"""Run the main command-line interface for beets. Includes top-level
exception handlers that print friendly error messages.
"""
if "AppData\\Local\\Microsoft\\WindowsApps" in sys.exec_prefix:
log.error(
"error: beets is unable to use the Microsoft Store version of "
"Python. Please install Python from https://python.org.\n"
"error: More details can be found here "
"https://beets.readthedocs.io/en/stable/guides/main.html"
)
sys.exit(1)
try:
_raw_main(args)
except UserError as exc:

View file

@ -85,17 +85,21 @@ def get_format(fmt=None):
return (command.encode("utf-8"), extension.encode("utf-8"))
def in_no_convert(item: Item) -> bool:
no_convert_query = config["convert"]["no_convert"].as_str()
if no_convert_query:
query, _ = parse_query_string(no_convert_query, Item)
return query.match(item)
else:
return False
def should_transcode(item, fmt):
"""Determine whether the item should be transcoded as part of
conversion (i.e., its bitrate is high or it has the wrong format).
"""
no_convert_queries = config["convert"]["no_convert"].as_str_seq()
if no_convert_queries:
for query_string in no_convert_queries:
query, _ = parse_query_string(query_string, Item)
if query.match(item):
return False
if (
if in_no_convert(item) or (
config["convert"]["never_convert_lossy_files"]
and item.format.lower() not in LOSSLESS_FORMATS
):

View file

@ -148,9 +148,6 @@ class ListenBrainzPlugin(BeetsPlugin):
return self._make_request(url)
def get_listenbrainz_playlists(self):
"""Returns a list of playlists created by ListenBrainz."""
import re
resp = self.get_playlists_createdfor(self.username)
playlists = resp.get("playlists")
listenbrainz_playlists = []
@ -159,35 +156,32 @@ class ListenBrainzPlugin(BeetsPlugin):
playlist_info = playlist.get("playlist")
if playlist_info.get("creator") == "listenbrainz":
title = playlist_info.get("title")
match = re.search(
r"(Missed Recordings of \d{4}|Discoveries of \d{4})", title
self._log.debug(f"Playlist title: {title}")
playlist_type = (
"Exploration" if "Exploration" in title else "Jams"
)
if "Exploration" in title:
playlist_type = "Exploration"
elif "Jams" in title:
playlist_type = "Jams"
elif match:
playlist_type = match.group(1)
else:
playlist_type = None
if "week of " in title:
if "week of" in title:
date_str = title.split("week of ")[1].split(" ")[0]
date = datetime.datetime.strptime(
date_str, "%Y-%m-%d"
).date()
else:
date = None
continue
identifier = playlist_info.get("identifier")
id = identifier.split("/")[-1]
if playlist_type in ["Jams", "Exploration"]:
listenbrainz_playlists.append(
{
"type": playlist_type,
"date": date,
"identifier": id,
"title": title,
}
)
listenbrainz_playlists.append(
{"type": playlist_type, "date": date, "identifier": id}
)
listenbrainz_playlists = sorted(
listenbrainz_playlists, key=lambda x: x["type"]
)
listenbrainz_playlists = sorted(
listenbrainz_playlists, key=lambda x: x["date"], reverse=True
)
for playlist in listenbrainz_playlists:
self._log.debug(
f'Playlist: {playlist["type"]} - {playlist["date"]}'
)
return listenbrainz_playlists
def get_playlist(self, identifier):
@ -199,17 +193,20 @@ class ListenBrainzPlugin(BeetsPlugin):
"""This function returns a list of tracks in the playlist."""
tracks = []
for track in playlist.get("playlist").get("track"):
identifier = track.get("identifier")
if isinstance(identifier, list):
identifier = identifier[0]
tracks.append(
{
"artist": track.get("creator"),
"identifier": track.get("identifier").split("/")[-1],
"artist": track.get("creator", "Unknown artist"),
"identifier": identifier.split("/")[-1],
"title": track.get("title"),
}
)
return self.get_track_info(tracks)
def get_track_info(self, tracks):
"""Returns a list of track info."""
track_info = []
for track in tracks:
identifier = track.get("identifier")
@ -242,25 +239,37 @@ class ListenBrainzPlugin(BeetsPlugin):
)
return track_info
def get_weekly_playlist(self, index):
"""Returns a list of weekly playlists based on the index."""
def get_weekly_playlist(self, playlist_type, most_recent=True):
# Fetch all playlists
playlists = self.get_listenbrainz_playlists()
playlist = self.get_playlist(playlists[index].get("identifier"))
self._log.info(f"Getting {playlist.get('playlist').get('title')}")
# Filter playlists by type
filtered_playlists = [
p for p in playlists if p["type"] == playlist_type
]
# Sort playlists by date in descending order
sorted_playlists = sorted(
filtered_playlists, key=lambda x: x["date"], reverse=True
)
# Select the most recent or older playlist based on the most_recent flag
selected_playlist = (
sorted_playlists[0] if most_recent else sorted_playlists[1]
)
self._log.debug(
f"Selected playlist: {selected_playlist['type']} "
f"- {selected_playlist['date']}"
)
# Fetch and return tracks from the selected playlist
playlist = self.get_playlist(selected_playlist.get("identifier"))
return self.get_tracks_from_playlist(playlist)
def get_weekly_exploration(self):
"""Returns a list of weekly exploration."""
return self.get_weekly_playlist(0)
return self.get_weekly_playlist("Exploration", most_recent=True)
def get_weekly_jams(self):
"""Returns a list of weekly jams."""
return self.get_weekly_playlist(1)
return self.get_weekly_playlist("Jams", most_recent=True)
def get_last_weekly_exploration(self):
"""Returns a list of weekly exploration."""
return self.get_weekly_playlist(3)
return self.get_weekly_playlist("Exploration", most_recent=False)
def get_last_weekly_jams(self):
"""Returns a list of weekly jams."""
return self.get_weekly_playlist(3)
return self.get_weekly_playlist("Jams", most_recent=False)

View file

@ -34,8 +34,7 @@ class Substitute(BeetsPlugin):
"""Do the actual replacing."""
if text:
for pattern, replacement in self.substitute_rules:
if pattern.match(text.lower()):
return replacement
text = pattern.sub(replacement, text)
return text
else:
return ""
@ -47,10 +46,8 @@ class Substitute(BeetsPlugin):
substitute rules.
"""
super().__init__()
self.substitute_rules = []
self.template_funcs["substitute"] = self.tmpl_substitute
for key, view in self.config.items():
value = view.as_str()
pattern = re.compile(key.lower())
self.substitute_rules.append((pattern, value))
self.substitute_rules = [
(re.compile(key, flags=re.IGNORECASE), value)
for key, value in self.config.flatten().items()
]

View file

@ -1,20 +1,57 @@
Changelog
=========
Changelog goes here! Please add your entry to the bottom of one of the lists below!
Unreleased
----------
Changelog goes here! Please add your entry to the bottom of one of the lists below!
New features:
Bug fixes:
For packagers:
Other changes:
2.2.0 (December 02, 2024)
-------------------------
New features:
* :doc:`/plugins/substitute`: Allow the replacement string to use capture groups
from the match. It is thus possible to create more general rules, applying to
many different artists at once.
Bug fixes:
* Check if running python from the Microsoft Store and provide feedback to install
from python.org.
:bug:`5467`
* Fix bug where matcher doesn't consider medium number when importing. This makes
it difficult to import hybrid SACDs and other releases with duplicate tracks.
:bug:`5148`
* Bring back test files and the manual to the source distribution tarball.
:bug:`5513`
For packagers:
Other changes:
* Changed `bitesize` label to `good first issue`. Our `contribute`_ page is now
automatically populated with these issues. :bug:`4855`
.. _contribute: https://github.com/beetbox/beets/contribute
2.1.0 (November 22, 2024)
-------------------------
New features:
* New template function added: ``%capitalize``. Converts the first letter of
the text to uppercase and the rest to lowercase.
* Ability to query albums with track db fields and vice-versa, for example
`beet list -a title:something` or `beet list artpath:cover`. Consequently
album queries involving `path` field have been sped up, like `beet list -a
path:/path/`.
* New `keep_in_artist` option for the :doc:`plugins/ftintitle` plugin, which
``beet list -a title:something`` or ``beet list artpath:cover``. Consequently
album queries involving ``path`` field have been sped up, like ``beet list -a
path:/path/``.
* :doc:`plugins/ftintitle`: New ``keep_in_artist`` option for the plugin, which
allows keeping the "feat." part in the artist metadata while still changing
the title.
* :doc:`plugins/autobpm`: Add new configuration option ``beat_track_kwargs``
@ -42,31 +79,36 @@ Bug fixes:
issues in the future.
:bug:`5289`
* :doc:`plugins/discogs`: Fix the ``TypeError`` when there is no description.
* Remove single quotes from all SQL queries
* Use single quotes in all SQL queries
:bug:`4709`
* :doc:`plugins/lyrics`: Update ``tekstowo`` backend to fetch lyrics directly
since recent updates to their website made it unsearchable.
:bug:`5456`
* :doc:`plugins/convert`: Fixed the convert plugin ``no_convert`` option so
that it no longer treats "and" and "or" queries the same. To maintain
previous behaviour add commas between your query keywords. For help see
:ref:`combiningqueries`.
* Fix the ``TypeError`` when :ref:`set_fields` is provided non-string values. :bug:`4840`
For packagers:
* The minimum supported Python version is now 3.8.
* The `beet` script has been removed from the repository.
* The `typing_extensions` is required for Python 3.10 and below.
* The ``beet`` script has been removed from the repository.
* The ``typing_extensions`` is required for Python 3.10 and below.
Other changes:
* :doc:`contributing`: The project now uses `poetry` for packaging and
* :doc:`contributing`: The project now uses ``poetry`` for packaging and
dependency management. This change affects project management and mostly
affects beets developers. Please see updates in :ref:`getting-the-source` and
:ref:`testing` for more information.
* :doc:`contributing`: Since `poetry` now manages local virtual environments,
`tox` has been replaced by a task runner `poethepoet`. This change affects
* :doc:`contributing`: Since ``poetry`` now manages local virtual environments,
`tox` has been replaced by a task runner ``poethepoet``. This change affects
beets developers and contributors. Please see updates in the
:ref:`development-tools` section for more details. Type ``poe`` while in
the project directory to see the available commands.
* Installation instructions have been made consistent across plugins
documentation. Users should simply install `beets` with an `extra` of the
documentation. Users should simply install ``beets`` with an ``extra`` of the
corresponding plugin name in order to install extra dependencies for that
plugin.
* GitHub workflows have been reorganised for clarity: style, linting, type and
@ -77,10 +119,10 @@ Other changes:
documentation is changed, and they only check the changed files. When
dependencies are updated (``poetry.lock``), then the entire code base is
checked.
* The long-deprecated `beets.util.confit` module has been removed. This may
* The long-deprecated ``beets.util.confit`` module has been removed. This may
cause extremely outdated external plugins to fail to load.
* :doc:`plugins/autobpm`: Add plugin dependencies to `pyproject.toml` under
the `autobpm` extra and update the plugin installation instructions in the
* :doc:`plugins/autobpm`: Add plugin dependencies to ``pyproject.toml`` under
the ``autobpm`` extra and update the plugin installation instructions in the
docs.
Since importing the bpm calculation functionality from ``librosa`` takes
around 4 seconds, update the plugin to only do so when it actually needs to
@ -253,6 +295,8 @@ New features:
Bug fixes:
* Improve ListenBrainz error handling.
:bug:`5459`
* :doc:`/plugins/deezer`: Improve requests error handling.
* :doc:`/plugins/lastimport`: Improve error handling in the `process_tracks` function and enable it to be used with other plugins.
* :doc:`/plugins/spotify`: Improve handling of ConnectionError.

View file

@ -11,8 +11,8 @@ master_doc = "index"
project = "beets"
copyright = "2016, Adrian Sampson"
version = "2.0"
release = "2.0.0"
version = "2.2"
release = "2.2.0"
pygments_style = "sphinx"

View file

@ -109,7 +109,7 @@ get it right:
should open the "System Properties" screen, then select the "Advanced" tab,
then hit the "Environmental Variables..." button, and then look for the PATH
variable in the table. Add the following to the end of the variable's value:
``;C:\Python37;C:\Python37\Scripts``. You may need to adjust these paths to
``;C:\Python38;C:\Python38\Scripts``. You may need to adjust these paths to
point to your Python installation.
3. Now install beets by running: ``pip install beets``

View file

@ -11,13 +11,34 @@ the ``rewrite`` plugin modifies the metadata, this plugin does not.
Enable the ``substitute`` plugin (see :ref:`using-plugins`), then make a ``substitute:`` section in your config file to contain your rules.
Each rule consists of a case-insensitive regular expression pattern, and a
replacement value. For example, you might use:
replacement string. For example, you might use:
.. code-block:: yaml
substitute:
.*jimi hendrix.*: Jimi Hendrix
.*jimi hendrix.*: Jimi Hendrix
The replacement can be an expression utilising the matched regex, allowing us
to create more general rules. Say for example, we want to sort all albums by
multiple artists into the directory of the first artist. We can thus capture
everything before the first ``,``, `` &`` or `` and``, and use this capture
group in the output, discarding the rest of the string.
.. code-block:: yaml
substitute:
^(.*?)(,| &| and).*: \1
This would handle all the below cases in a single rule:
Bob Dylan and The Band -> Bob Dylan
Neil Young & Crazy Horse -> Neil Young
James Yorkston, Nina Persson & The Second Hand Orchestra -> James Yorkston
To apply the substitution, you have to call the function ``%substitute{}`` in the paths section. For example:
.. code-block:: yaml
paths:
default: %substitute{$albumartist}/$year - $album%aunique{}/$track - $title
default: \%substitute{$albumartist}/$year - $album\%aunique{}/$track - $title

View file

@ -36,9 +36,10 @@ fields to nullify and the conditions for nullifying them:
For example::
zero:
fields: month day genre comments
fields: month day genre genres comments
comments: [EAC, LAME, from.+collection, 'ripped by']
genre: [rnb, 'power metal']
genres: [rnb, 'power metal']
update_database: true
If a custom pattern is not defined for a given field, the field will be nulled
@ -60,4 +61,4 @@ art from files' tags unless you tell it not to. To keep the album art, include
the special field ``images`` in the list. For example::
zero:
keep_fields: title artist album year track genre images
keep_fields: title artist album year track genre genres images

Binary file not shown.

View file

@ -40,7 +40,10 @@ def update_changelog(text: str, new: Version) -> str:
Unreleased
----------
Changelog goes here! Please add your entry to the bottom of one of the lists below!
New features:
Bug fixes:
For packagers:
Other changes:
{new_header}
{'-' * len(new_header)}

1094
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "beets"
version = "2.0.0"
version = "2.2.0"
description = "music tagger and library organizer"
authors = ["Adrian Sampson <adrian@radbox.org>"]
maintainers = ["Serene-Arc"]
@ -26,6 +26,7 @@ packages = [
{ include = "beets" },
{ include = "beetsplug" },
]
include = ["test", "man/**/*"] # extra files to include in the sdist
[tool.poetry.urls]
Changelog = "https://github.com/beetbox/beets/blob/master/docs/changelog.rst"
@ -67,6 +68,9 @@ resampy = { version = ">=0.4.3", optional = true }
requests-oauthlib = { version = ">=0.6.1", optional = true }
soco = { version = "*", optional = true }
pydata-sphinx-theme = { version = "*", optional = true }
sphinx = { version = "*", optional = true }
[tool.poetry.group.test.dependencies]
beautifulsoup4 = "*"
codecov = ">=2.1.13"
@ -96,10 +100,6 @@ types-PyYAML = "*"
types-requests = "*"
types-urllib3 = "*"
[tool.poetry.group.docs.dependencies]
pydata-sphinx-theme = "*"
sphinx = "*"
[tool.poetry.group.release.dependencies]
click = ">=8.1.7"
packaging = ">=24.0"
@ -115,6 +115,7 @@ beatport = ["requests-oauthlib"]
bpd = ["PyGObject"] # python-gi and GStreamer 1.0+
chroma = ["pyacoustid"] # chromaprint or fpcalc
# convert # ffmpeg
docs = ["pydata-sphinx-theme", "sphinx"]
discogs = ["python3-discogs-client"]
embedart = ["Pillow"] # ImageMagick
embyupdate = ["requests"]
@ -149,6 +150,15 @@ build-backend = "poetry.core.masonry.api"
poethepoet = ">=0.26"
poetry = ">=1.8"
[tool.poe.tasks.build]
help = "Build the package"
shell = """
make -C docs man
rm -rf man
mv docs/_build/man .
poetry build
"""
[tool.poe.tasks.bump]
help = "Bump project version and update relevant files"
cmd = "python ./extra/release.py bump $version"

View file

@ -14,6 +14,7 @@ markers =
data_file = .reports/coverage/data
branch = true
relative_files = true
omit = beets/test/*
[coverage:report]
precision = 2

View file

@ -91,6 +91,7 @@ class TestAuraResponse:
"artist": item.artist,
"size": Path(os.fsdecode(item.path)).stat().st_size,
"title": item.title,
"track": 1,
},
"relationships": {
"albums": {"data": [{"id": str(album.id), "type": "album"}]},

View file

@ -1,7 +1,14 @@
import importlib.util
import os
import pytest
from beets.test.helper import ImportHelper, PluginMixin
github_ci = os.environ.get("GITHUB_ACTIONS") == "true"
if not github_ci and not importlib.util.find_spec("librosa"):
pytest.skip("librosa isn't available", allow_module_level=True)
class TestAutoBPMPlugin(PluginMixin, ImportHelper):
plugin = "autobpm"

View file

@ -19,9 +19,11 @@ import re
import sys
import unittest
import pytest
from mediafile import MediaFile
from beets import util
from beets.library import Item
from beets.test import _common
from beets.test.helper import (
AsIsImporterMixin,
@ -31,6 +33,7 @@ from beets.test.helper import (
control_stdin,
)
from beets.util import bytestring_path, displayable_path
from beetsplug import convert
def shell_quote(text):
@ -335,3 +338,21 @@ class NeverConvertLossyFilesTest(ConvertTestCase, ConvertCommand):
self.run_convert_path(item.path)
converted = os.path.join(self.convert_dest, b"converted.ogg")
self.assertNoFileTag(converted, "mp3")
class TestNoConvert:
"""Test the effect of the `no_convert` option."""
@pytest.mark.parametrize(
"config_value, should_skip",
[
("", False),
("bitrate:320", False),
("bitrate:320 format:ogg", False),
("bitrate:320 , format:ogg", True),
],
)
def test_no_convert_skip(self, config_value, should_skip):
item = Item(format="ogg", bitrate=256)
convert.config["convert"]["no_convert"] = config_value
assert convert.in_no_convert(item) == should_skip

View file

@ -12,17 +12,11 @@
# 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 beets import config
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.library import Item
from beets.test.helper import (
PluginTestCase,
capture_log,
generate_album_info,
generate_track_info,
)
from beets.test.helper import PluginTestCase, capture_log
class MbsyncCliTest(PluginTestCase):
@ -31,28 +25,28 @@ class MbsyncCliTest(PluginTestCase):
@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):
album_for_id.return_value = generate_album_info(
"album id", [("track id", {"release_track_id": "release track id"})]
)
track_for_id.return_value = generate_track_info(
"singleton track id", {"title": "singleton info"}
)
album_item = Item(
album="old title",
album="old album",
mb_albumid="81ae60d4-5b75-38df-903a-db2cfa51c2c6",
mb_trackid="old track id",
mb_releasetrackid="release track id",
path="",
mb_trackid="track id",
)
album = self.lib.add_album([album_item])
self.lib.add_album([album_item])
item = Item(
title="old title",
mb_trackid="b8c2cf90-83f9-3b5f-8ccd-31fb866fcf37",
path="",
singleton = Item(
title="old title", mb_trackid="b8c2cf90-83f9-3b5f-8ccd-31fb866fcf37"
)
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"
)
self.lib.add(item)
with capture_log() as logs:
self.run_command("mbsync")
@ -60,130 +54,36 @@ class MbsyncCliTest(PluginTestCase):
assert "Sending event: albuminfo_received" in logs
assert "Sending event: trackinfo_received" in logs
item.load()
assert item.title == "singleton info"
singleton.load()
assert singleton.title == "new title"
album_item.load()
assert album_item.title == "track info"
assert album_item.title == "new title"
assert album_item.mb_trackid == "track id"
assert album_item.get_album().album == "new album"
album.load()
assert album.album == "album info"
def test_custom_format(self):
for item in [
Item(artist="albumartist", album="no id"),
Item(
artist="albumartist",
album="invalid id",
mb_albumid="a1b2c3d4",
),
]:
self.lib.add_album([item])
def test_message_when_skipping(self):
config["format_item"] = "$artist - $album - $title"
config["format_album"] = "$albumartist - $album"
for item in [
Item(artist="artist", title="no id"),
Item(artist="artist", title="invalid id", mb_trackid="a1b2c3d4"),
]:
self.lib.add(item)
# Test album with no mb_albumid.
# The default format for an album include $albumartist so
# set that here, too.
album_invalid = Item(
albumartist="album info", album="album info", path=""
)
self.lib.add_album([album_invalid])
# default format
with capture_log("beets.mbsync") as logs:
self.run_command("mbsync")
e = (
"mbsync: Skipping album with no mb_albumid: "
+ "album info - album info"
)
assert e == logs[0]
# custom format
with capture_log("beets.mbsync") as logs:
self.run_command("mbsync", "-f", "'$album'")
e = "mbsync: Skipping album with no mb_albumid: 'album info'"
assert e == logs[0]
# restore the config
config["format_item"] = "$artist - $album - $title"
config["format_album"] = "$albumartist - $album"
# Test singleton with no mb_trackid.
# The default singleton format includes $artist and $album
# so we need to stub them here
item_invalid = Item(
artist="album info",
album="album info",
title="old title",
path="",
)
self.lib.add(item_invalid)
# default format
with capture_log("beets.mbsync") as logs:
self.run_command("mbsync")
e = (
"mbsync: Skipping singleton with no mb_trackid: "
+ "album info - album info - old title"
)
assert e == logs[0]
# custom format
with capture_log("beets.mbsync") as logs:
self.run_command("mbsync", "-f", "'$title'")
e = "mbsync: Skipping singleton with no mb_trackid: 'old title'"
assert e == logs[0]
def test_message_when_invalid(self):
config["format_item"] = "$artist - $album - $title"
config["format_album"] = "$albumartist - $album"
# Test album with invalid mb_albumid.
# The default format for an album include $albumartist so
# set that here, too.
album_invalid = Item(
albumartist="album info",
album="album info",
mb_albumid="a1b2c3d4",
path="",
)
self.lib.add_album([album_invalid])
# default format
with capture_log("beets.mbsync") as logs:
self.run_command("mbsync")
e = (
"mbsync: Skipping album with invalid mb_albumid: "
+ "album info - album info"
)
assert e == logs[0]
# custom format
with capture_log("beets.mbsync") as logs:
self.run_command("mbsync", "-f", "'$album'")
e = "mbsync: Skipping album with invalid mb_albumid: 'album info'"
assert e == logs[0]
# restore the config
config["format_item"] = "$artist - $album - $title"
config["format_album"] = "$albumartist - $album"
# Test singleton with invalid mb_trackid.
# The default singleton format includes $artist and $album
# so we need to stub them here
item_invalid = Item(
artist="album info",
album="album info",
title="old title",
mb_trackid="a1b2c3d4",
path="",
)
self.lib.add(item_invalid)
# default format
with capture_log("beets.mbsync") as logs:
self.run_command("mbsync")
e = (
"mbsync: Skipping singleton with invalid mb_trackid: "
+ "album info - album info - old title"
)
assert e == logs[0]
# custom format
with capture_log("beets.mbsync") as logs:
self.run_command("mbsync", "-f", "'$title'")
e = "mbsync: Skipping singleton with invalid mb_trackid: 'old title'"
assert e == logs[0]
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'",
}

View file

@ -0,0 +1,90 @@
# This file is part of beets.
# Copyright 2024, Nicholas Boyd Isacsson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Test the substitute plugin regex functionality."""
from beets.test.helper import PluginTestCase
from beetsplug.substitute import Substitute
class SubstitutePluginTest(PluginTestCase):
plugin = "substitute"
preload_plugin = False
def run_substitute(self, config, cases):
with self.configure_plugin(config):
for input, expected in cases:
assert Substitute().tmpl_substitute(input) == expected
def test_simple_substitute(self):
self.run_substitute(
{
"a": "x",
"b": "y",
"c": "z",
},
[("a", "x"), ("b", "y"), ("c", "z")],
)
def test_case_insensitivity(self):
self.run_substitute({"a": "x"}, [("A", "x")])
def test_unmatched_input_preserved(self):
self.run_substitute({"a": "x"}, [("c", "c")])
def test_regex_to_static(self):
self.run_substitute(
{".*jimi hendrix.*": "Jimi Hendrix"},
[("The Jimi Hendrix Experience", "Jimi Hendrix")],
)
def test_regex_capture_group(self):
self.run_substitute(
{"^(.*?)(,| &| and).*": r"\1"},
[
("King Creosote & Jon Hopkins", "King Creosote"),
(
"Michael Hurley, The Holy Modal Rounders, Jeffrey Frederick & "
+ "The Clamtones",
"Michael Hurley",
),
("James Yorkston and the Athletes", "James Yorkston"),
],
)
def test_partial_substitution(self):
self.run_substitute({r"\.": ""}, [("U.N.P.O.C.", "UNPOC")])
def test_rules_applied_in_definition_order(self):
self.run_substitute(
{
"a": "x",
"[ab]": "y",
"b": "z",
},
[
("a", "x"),
("b", "y"),
],
)
def test_rules_applied_in_sequence(self):
self.run_substitute(
{"a": "b", "b": "c", "d": "a"},
[
("a", "c"),
("b", "c"),
("d", "a"),
],
)

View file

@ -395,11 +395,13 @@ class ImportSingletonTest(ImportTestCase):
def test_set_fields(self):
genre = "\U0001f3b7 Jazz"
collection = "To Listen"
disc = 0
config["import"]["set_fields"] = {
"collection": collection,
"genre": genre,
"title": "$title - formatted",
"disc": disc,
}
# As-is item import.
@ -412,6 +414,7 @@ class ImportSingletonTest(ImportTestCase):
assert item.genre == genre
assert item.collection == collection
assert item.title == "Tag Track 1 - formatted"
assert item.disc == disc
# Remove item from library to test again with APPLY choice.
item.remove()
@ -426,6 +429,7 @@ class ImportSingletonTest(ImportTestCase):
assert item.genre == genre
assert item.collection == collection
assert item.title == "Applied Track 1 - formatted"
assert item.disc == disc
class ImportTest(ImportTestCase):
@ -583,12 +587,14 @@ class ImportTest(ImportTestCase):
genre = "\U0001f3b7 Jazz"
collection = "To Listen"
comments = "managed by beets"
disc = 0
config["import"]["set_fields"] = {
"genre": genre,
"collection": collection,
"comments": comments,
"album": "$album - formatted",
"disc": disc,
}
# As-is album import.
@ -608,6 +614,7 @@ class ImportTest(ImportTestCase):
item.get("album", with_album=False)
== "Tag Album - formatted"
)
assert item.disc == disc
# Remove album from library to test again with APPLY choice.
album.remove()
@ -629,6 +636,7 @@ class ImportTest(ImportTestCase):
item.get("album", with_album=False)
== "Applied Album - formatted"
)
assert item.disc == disc
class ImportTracksTest(ImportTestCase):

View file

@ -30,6 +30,7 @@ from mediafile import MediaFile, UnreadableFileError
import beets.dbcore.query
import beets.library
from beets import config, plugins, util
from beets.library import Album
from beets.test import _common
from beets.test._common import item
from beets.test.helper import BeetsTestCase, ItemInDBTestCase
@ -81,8 +82,7 @@ class StoreTest(ItemInDBTestCase):
assert "composer" not in self.i._dirty
def test_store_album_cascades_flex_deletes(self):
album = _common.album()
album.flex1 = "Flex-1"
album = Album(flex1="Flex-1")
self.lib.add(album)
item = _common.item()
item.album_id = album.id

View file

@ -16,6 +16,7 @@
import beets.library
from beets import config, dbcore
from beets.library import Album
from beets.test import _common
from beets.test.helper import BeetsTestCase
@ -26,28 +27,32 @@ class DummyDataTestCase(BeetsTestCase):
def setUp(self):
super().setUp()
albums = [_common.album() for _ in range(3)]
albums[0].album = "Album A"
albums[0].genre = "Rock"
albums[0].year = 2001
albums[0].flex1 = "Flex1-1"
albums[0].flex2 = "Flex2-A"
albums[0].albumartist = "Foo"
albums[0].albumartist_sort = None
albums[1].album = "Album B"
albums[1].genre = "Rock"
albums[1].year = 2001
albums[1].flex1 = "Flex1-2"
albums[1].flex2 = "Flex2-A"
albums[1].albumartist = "Bar"
albums[1].albumartist_sort = None
albums[2].album = "Album C"
albums[2].genre = "Jazz"
albums[2].year = 2005
albums[2].flex1 = "Flex1-1"
albums[2].flex2 = "Flex2-B"
albums[2].albumartist = "Baz"
albums[2].albumartist_sort = None
albums = [
Album(
album="Album A",
genre="Rock",
year=2001,
flex1="Flex1-1",
flex2="Flex2-A",
albumartist="Foo",
),
Album(
album="Album B",
genre="Rock",
year=2001,
flex1="Flex1-2",
flex2="Flex2-A",
albumartist="Bar",
),
Album(
album="Album C",
genre="Jazz",
year=2005,
flex1="Flex1-1",
flex2="Flex2-B",
albumartist="Baz",
),
]
for album in albums:
self.lib.add(album)
@ -378,14 +383,14 @@ class CaseSensitivityTest(DummyDataTestCase, BeetsTestCase):
def setUp(self):
super().setUp()
album = _common.album()
album.album = "album"
album.genre = "alternative"
album.year = "2001"
album.flex1 = "flex1"
album.flex2 = "flex2-A"
album.albumartist = "bar"
album.albumartist_sort = None
album = Album(
album="album",
genre="alternative",
year="2001",
flex1="flex1",
flex2="flex2-A",
albumartist="bar",
)
self.lib.add(album)
item = _common.item()