mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 16:42:42 +01:00
Merge branch 'beetbox:master' into arm-info
This commit is contained in:
commit
eafae03560
32 changed files with 1027 additions and 906 deletions
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
|
|
@ -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) }}
|
||||
|
|
|
|||
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
28
.github/workflows/make_release.yaml
vendored
28
.github/workflows/make_release.yaml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from sys import stderr
|
|||
|
||||
import confuse
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__version__ = "2.2.0"
|
||||
__author__ = "Adrian Sampson <adrian@radbox.org>"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ match:
|
|||
track_index: 1.0
|
||||
track_length: 2.0
|
||||
track_id: 5.0
|
||||
medium: 1.0
|
||||
preferred:
|
||||
countries: []
|
||||
media: []
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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,34 +156,31 @@ 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,
|
||||
}
|
||||
{"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
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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``
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
extra/beets.reg
BIN
extra/beets.reg
Binary file not shown.
|
|
@ -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
1094
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ markers =
|
|||
data_file = .reports/coverage/data
|
||||
branch = true
|
||||
relative_files = true
|
||||
omit = beets/test/*
|
||||
|
||||
[coverage:report]
|
||||
precision = 2
|
||||
|
|
|
|||
|
|
@ -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"}]},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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_message_when_skipping(self):
|
||||
config["format_item"] = "$artist - $album - $title"
|
||||
config["format_album"] = "$albumartist - $album"
|
||||
|
||||
# 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",
|
||||
def test_custom_format(self):
|
||||
for item in [
|
||||
Item(artist="albumartist", album="no id"),
|
||||
Item(
|
||||
artist="albumartist",
|
||||
album="invalid id",
|
||||
mb_albumid="a1b2c3d4",
|
||||
path="",
|
||||
)
|
||||
self.lib.add_album([album_invalid])
|
||||
),
|
||||
]:
|
||||
self.lib.add_album([item])
|
||||
|
||||
for item in [
|
||||
Item(artist="artist", title="no id"),
|
||||
Item(artist="artist", title="invalid id", mb_trackid="a1b2c3d4"),
|
||||
]:
|
||||
self.lib.add(item)
|
||||
|
||||
# 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'",
|
||||
}
|
||||
|
|
|
|||
90
test/plugins/test_substitute.py
Normal file
90
test/plugins/test_substitute.py
Normal 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"),
|
||||
],
|
||||
)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue