Merge branch 'master' into master

This commit is contained in:
RollingStar 2023-12-17 10:48:31 -05:00 committed by GitHub
commit d927262bd5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 653 additions and 72 deletions

View file

@ -444,14 +444,29 @@ def import_stages():
# New-style (lazy) plugin-provided fields.
def _check_conflicts_and_merge(plugin, plugin_funcs, funcs):
"""Check the provided template functions for conflicts and merge into funcs.
Raises a `PluginConflictException` if a plugin defines template functions
for fields that another plugin has already defined template functions for.
"""
if plugin_funcs:
if not plugin_funcs.keys().isdisjoint(funcs.keys()):
conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys())
raise PluginConflictException(
f"Plugin {plugin.name} defines template functions for "
f"{conflicted_fields} that conflict with another plugin."
)
funcs.update(plugin_funcs)
def item_field_getters():
"""Get a dictionary mapping field names to unary functions that
compute the field's value.
"""
funcs = {}
for plugin in find_plugins():
if plugin.template_fields:
funcs.update(plugin.template_fields)
_check_conflicts_and_merge(plugin, plugin.template_fields, funcs)
return funcs
@ -459,8 +474,7 @@ def album_field_getters():
"""As above, for album fields."""
funcs = {}
for plugin in find_plugins():
if plugin.album_template_fields:
funcs.update(plugin.album_template_fields)
_check_conflicts_and_merge(plugin, plugin.album_template_fields, funcs)
return funcs

View file

@ -591,7 +591,7 @@ def colorize(color_name, text):
"""Colorize text if colored output is enabled. (Like _colorize but
conditional.)
"""
if config["ui"]["color"]:
if config["ui"]["color"] and "NO_COLOR" not in os.environ:
global COLORS
if not COLORS:
# Read all color configurations and set global variable COLORS.

View file

@ -1460,6 +1460,12 @@ import_cmd.parser.add_option(
dest="quiet",
help="never prompt for input: skip albums instead",
)
import_cmd.parser.add_option(
"--quiet-fallback",
type="string",
dest="quiet_fallback",
help="decision in quiet mode when no strong match: skip or asis",
)
import_cmd.parser.add_option(
"-l",
"--log",
@ -1500,6 +1506,20 @@ import_cmd.parser.add_option(
action="store_false",
help="do not skip already-imported directories",
)
import_cmd.parser.add_option(
"-R",
"--incremental-skip-later",
action="store_true",
dest="incremental_skip_later",
help="do not record skipped files during incremental import",
)
import_cmd.parser.add_option(
"-r",
"--noincremental-skip-later",
action="store_false",
dest="incremental_skip_later",
help="record skipped files during incremental import",
)
import_cmd.parser.add_option(
"--from-scratch",
dest="from_scratch",

View file

@ -14,18 +14,40 @@
"""Plugin to rewrite fields based on a given query."""
import re
import shlex
from collections import defaultdict
import confuse
from beets import ui
from beets.dbcore import AndQuery, query_from_strings
from beets.dbcore.types import MULTI_VALUE_DSV
from beets.library import Album, Item
from beets.plugins import BeetsPlugin
from beets.ui import UserError
def rewriter(field, rules):
def simple_rewriter(field, rules):
"""Template field function factory.
Create a template field function that rewrites the given field
with the given rewriting rules.
``rules`` must be a list of (pattern, replacement) pairs.
"""
def fieldfunc(item):
value = item._values_fixed[field]
for pattern, replacement in rules:
if pattern.match(value.lower()):
# Rewrite activated.
return replacement
# Not activated; return original value.
return value
return fieldfunc
def advanced_rewriter(field, rules):
"""Template field function factory.
Create a template field function that rewrites the given field
@ -53,40 +75,117 @@ class AdvancedRewritePlugin(BeetsPlugin):
super().__init__()
template = confuse.Sequence(
{
"match": str,
"field": str,
"replacement": str,
}
confuse.OneOf(
[
confuse.MappingValues(str),
{
"match": str,
"replacements": confuse.MappingValues(
confuse.OneOf([str, confuse.Sequence(str)]),
),
},
]
)
)
# Used to apply the same rewrite to the corresponding album field.
corresponding_album_fields = {
"artist": "albumartist",
"artists": "albumartists",
"artist_sort": "albumartist_sort",
"artists_sort": "albumartists_sort",
}
# Gather all the rewrite rules for each field.
rules = defaultdict(list)
simple_rules = defaultdict(list)
advanced_rules = defaultdict(list)
for rule in self.config.get(template):
query = query_from_strings(
AndQuery,
Item,
prefixes={},
query_parts=shlex.split(rule["match"]),
)
fieldname = rule["field"]
replacement = rule["replacement"]
if fieldname not in Item._fields:
raise ui.UserError(
"invalid field name (%s) in rewriter" % fieldname
if "match" not in rule:
# Simple syntax
if len(rule) != 1:
raise UserError(
"Simple rewrites must have only one rule, "
"but found multiple entries. "
"Did you forget to prepend a dash (-)?"
)
key, value = next(iter(rule.items()))
try:
fieldname, pattern = key.split(None, 1)
except ValueError:
raise UserError(
f"Invalid simple rewrite specification {key}"
)
if fieldname not in Item._fields:
raise UserError(
f"invalid field name {fieldname} in rewriter"
)
self._log.debug(
f"adding simple rewrite '{pattern}''{value}' "
f"for field {fieldname}"
)
self._log.debug(
"adding template field {0}{1}", fieldname, replacement
)
rules[fieldname].append((query, replacement))
if fieldname == "artist":
# Special case for the artist field: apply the same
# rewrite for "albumartist" as well.
rules["albumartist"].append((query, replacement))
pattern = re.compile(pattern.lower())
simple_rules[fieldname].append((pattern, value))
# Apply the same rewrite to the corresponding album field.
if fieldname in corresponding_album_fields:
album_fieldname = corresponding_album_fields[fieldname]
simple_rules[album_fieldname].append((pattern, value))
else:
# Advanced syntax
match = rule["match"]
replacements = rule["replacements"]
if len(replacements) == 0:
raise UserError(
"Advanced rewrites must have at least one replacement"
)
query = query_from_strings(
AndQuery,
Item,
prefixes={},
query_parts=shlex.split(match),
)
for fieldname, replacement in replacements.items():
if fieldname not in Item._fields:
raise UserError(
f"Invalid field name {fieldname} in rewriter"
)
self._log.debug(
f"adding advanced rewrite to '{replacement}' "
f"for field {fieldname}"
)
if isinstance(replacement, list):
if Item._fields[fieldname] is not MULTI_VALUE_DSV:
raise UserError(
f"Field {fieldname} is not a multi-valued field "
f"but a list was given: {', '.join(replacement)}"
)
elif isinstance(replacement, str):
if Item._fields[fieldname] is MULTI_VALUE_DSV:
replacement = list(replacement)
else:
raise UserError(
f"Invalid type of replacement {replacement} "
f"for field {fieldname}"
)
advanced_rules[fieldname].append((query, replacement))
# Apply the same rewrite to the corresponding album field.
if fieldname in corresponding_album_fields:
album_fieldname = corresponding_album_fields[fieldname]
advanced_rules[album_fieldname].append(
(query, replacement)
)
# Replace each template field with the new rewriter function.
for fieldname, fieldrules in rules.items():
getter = rewriter(fieldname, fieldrules)
for fieldname, fieldrules in simple_rules.items():
getter = simple_rewriter(fieldname, fieldrules)
self.template_fields[fieldname] = getter
if fieldname in Album._fields:
self.album_template_fields[fieldname] = getter
for fieldname, fieldrules in advanced_rules.items():
getter = advanced_rewriter(fieldname, fieldrules)
self.template_fields[fieldname] = getter
if fieldname in Album._fields:
self.album_template_fields[fieldname] = getter

View file

@ -21,11 +21,13 @@ implemented by MusicBrainz yet.
[1] https://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings
"""
import subprocess
from beets import ui
from beets.autotag import Recommendation
from beets.plugins import BeetsPlugin
from beets.ui.commands import PromptChoice
from beets.util import displayable_path
from beetsplug.info import print_data
@ -37,6 +39,7 @@ class MBSubmitPlugin(BeetsPlugin):
{
"format": "$track. $title - $artist ($length)",
"threshold": "medium",
"picard_path": "picard",
}
)
@ -56,7 +59,21 @@ class MBSubmitPlugin(BeetsPlugin):
def before_choose_candidate_event(self, session, task):
if task.rec <= self.threshold:
return [PromptChoice("p", "Print tracks", self.print_tracks)]
return [
PromptChoice("p", "Print tracks", self.print_tracks),
PromptChoice("o", "Open files with Picard", self.picard),
]
def picard(self, session, task):
paths = []
for p in task.paths:
paths.append(displayable_path(p))
try:
picard_path = self.config["picard_path"].as_str()
subprocess.Popen([picard_path] + paths)
self._log.info("launched picard from\n{}", picard_path)
except OSError as exc:
self._log.error(f"Could not open picard, got error:\n{exc}")
def print_tracks(self, session, task):
for i in sorted(task.items, key=lambda i: i.track):

View file

@ -45,10 +45,12 @@ class SmartPlaylistPlugin(BeetsPlugin):
"playlist_dir": ".",
"auto": True,
"playlists": [],
"uri_format": None,
"forward_slash": False,
"prefix": "",
"urlencode": False,
"pretend_paths": False,
"output": "m3u",
}
)
@ -71,6 +73,54 @@ class SmartPlaylistPlugin(BeetsPlugin):
action="store_true",
help="display query results but don't write playlist files.",
)
spl_update.parser.add_option(
"--pretend-paths",
action="store_true",
dest="pretend_paths",
help="in pretend mode, log the playlist item URIs/paths.",
)
spl_update.parser.add_option(
"-d",
"--playlist-dir",
dest="playlist_dir",
metavar="PATH",
type="string",
help="directory to write the generated playlist files to.",
)
spl_update.parser.add_option(
"--relative-to",
dest="relative_to",
metavar="PATH",
type="string",
help="generate playlist item paths relative to this path.",
)
spl_update.parser.add_option(
"--prefix",
type="string",
help="prepend string to every path in the playlist file.",
)
spl_update.parser.add_option(
"--forward-slash",
action="store_true",
dest="forward_slash",
help="force forward slash in paths within playlists.",
)
spl_update.parser.add_option(
"--urlencode",
action="store_true",
help="URL-encode all paths.",
)
spl_update.parser.add_option(
"--uri-format",
dest="uri_format",
type="string",
help="playlist item URI template, e.g. http://beets:8337/item/$id/file.",
)
spl_update.parser.add_option(
"--output",
type="string",
help="specify the playlist format: m3u|m3u8.",
)
spl_update.func = self.update_cmd
return [spl_update]
@ -99,8 +149,14 @@ class SmartPlaylistPlugin(BeetsPlugin):
else:
self._matched_playlists = self._unmatched_playlists
self.__apply_opts_to_config(opts)
self.update_playlists(lib, opts.pretend)
def __apply_opts_to_config(self, opts):
for k, v in opts.__dict__.items():
if v is not None and k in self.config:
self.config[k] = v
def build_queries(self):
"""
Instantiate queries for the playlists.
@ -198,6 +254,8 @@ class SmartPlaylistPlugin(BeetsPlugin):
playlist_dir = self.config["playlist_dir"].as_filename()
playlist_dir = bytestring_path(playlist_dir)
tpl = self.config["uri_format"].get()
prefix = bytestring_path(self.config["prefix"].as_str())
relative_to = self.config["relative_to"].get()
if relative_to:
relative_to = normpath(relative_to)
@ -226,31 +284,49 @@ class SmartPlaylistPlugin(BeetsPlugin):
m3u_name = sanitize_path(m3u_name, lib.replacements)
if m3u_name not in m3us:
m3us[m3u_name] = []
item_path = item.path
if relative_to:
item_path = os.path.relpath(item.path, relative_to)
if item_path not in m3us[m3u_name]:
m3us[m3u_name].append(item_path)
item_uri = item.path
if tpl:
item_uri = tpl.replace("$id", str(item.id)).encode("utf-8")
else:
if relative_to:
item_uri = os.path.relpath(item_uri, relative_to)
if self.config["forward_slash"].get():
item_uri = path_as_posix(item_uri)
if self.config["urlencode"]:
item_uri = bytestring_path(pathname2url(item_uri))
item_uri = prefix + item_uri
if item_uri not in m3us[m3u_name]:
m3us[m3u_name].append({"item": item, "uri": item_uri})
if pretend and self.config["pretend_paths"]:
print(displayable_path(item_path))
print(displayable_path(item_uri))
elif pretend:
print(item)
if not pretend:
prefix = bytestring_path(self.config["prefix"].as_str())
# Write all of the accumulated track lists to files.
for m3u in m3us:
m3u_path = normpath(
os.path.join(playlist_dir, bytestring_path(m3u))
)
mkdirall(m3u_path)
pl_format = self.config["output"].get()
if pl_format != "m3u" and pl_format != "m3u8":
msg = "Unsupported output format '{}' provided! "
msg += "Supported: m3u, m3u8"
raise Exception(msg.format(pl_format))
m3u8 = pl_format == "m3u8"
with open(syspath(m3u_path), "wb") as f:
for path in m3us[m3u]:
if self.config["forward_slash"].get():
path = path_as_posix(path)
if self.config["urlencode"]:
path = bytestring_path(pathname2url(path))
f.write(prefix + path + b"\n")
if m3u8:
f.write(b"#EXTM3U\n")
for entry in m3us[m3u]:
item = entry["item"]
comment = ""
if m3u8:
comment = "#EXTINF:{},{} - {}\n".format(
int(item.length), item.artist, item.title
)
f.write(comment.encode("utf-8") + entry["uri"] + b"\n")
# Send an event when playlists were updated.
send_event("smartplaylist_update")

View file

@ -184,6 +184,9 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
except requests.exceptions.ReadTimeout:
self._log.error("ReadTimeout.")
raise SpotifyAPIError("Request timed out.")
except requests.exceptions.ConnectionError as e:
self._log.error(f"Network error: {e}")
raise SpotifyAPIError("Network error.")
except requests.exceptions.RequestException as e:
if e.response.status_code == 401:
self._log.debug(

View file

@ -13,10 +13,11 @@ Major new features:
* The beets importer UI received a major overhaul. Several new configuration
options are available for customizing layout and colors: :ref:`ui_options`.
:bug:`3721`
:bug:`3721` :bug:`5028`
New features:
* :doc:`plugins/mbsubmit`: add new prompt choices helping further to submit unmatched tracks to MusicBrainz faster.
* :doc:`plugins/spotify`: We now fetch track's ISRC, EAN, and UPC identifiers from Spotify when using the ``spotifysync`` command.
:bug:`4992`
* :doc:`plugins/discogs`: supply a value for the `cover_art_url` attribute, for use by `fetchart`.
@ -145,11 +146,17 @@ New features:
plugin which allows to replace fields based on a given library query.
* :doc:`/plugins/lyrics`: Add LRCLIB as a new lyrics provider and a new
`synced` option to prefer synced lyrics over plain lyrics.
* :ref:`import-cmd`: Expose import.quiet_fallback as CLI option.
* :ref:`import-cmd`: Expose `import.incremental_skip_later` as CLI option.
* :doc:`/plugins/smartplaylist`: Expose config options as CLI options.
* :doc:`/plugins/smartplaylist`: Add new option `smartplaylist.output`.
* :doc:`/plugins/smartplaylist`: Add new option `smartplaylist.uri_format`.
* Sorted the default configuration file into categories.
:bug:`4987`
Bug fixes:
* :doc:`/plugins/spotify`: Improve handling of ConnectionError.
* :doc:`/plugins/deezer`: Improve Deezer plugin error handling and set requests timeout to 10 seconds.
:bug:`4983`
* :doc:`/plugins/spotify`: Add bad gateway (502) error handling.
@ -266,11 +273,22 @@ Bug fixes:
:bug:`4822`
* Fix bug where an interrupted import process poisons the database, causing
a null path that can't be removed.
* Fix bug where empty artist and title fields would return None instead of an
empty list in the discord plugin. :bug:`4973`
:bug:`4906`
* :doc:`/plugins/discogs`: Fix bug where empty artist and title fields would
return None instead of an empty list.
:bug:`4973`
* Fix bug regarding displaying tracks that have been changed not being
displayed unless the detail configuration is enabled.
For plugin developers:
* beets now explicitly prevents multiple plugins to define replacement
functions for the same field. When previously defining `template_fields`
for the same field in two plugins, the last loaded plugin would silently
overwrite the function defined by the other plugin.
Now, beets will raise an exception when this happens.
:bug:`5002`
For packagers:
* As noted above, the minimum Python version is now 3.7.

View file

@ -3,37 +3,89 @@ Advanced Rewrite Plugin
The ``advancedrewrite`` plugin lets you easily substitute values
in your templates and path formats, similarly to the :doc:`/plugins/rewrite`.
Please make sure to read the documentation of that plugin first.
It's recommended to read the documentation of that plugin first.
The *advanced* rewrite plugin doesn't match the rewritten field itself,
The *advanced* rewrite plugin does not only support the simple rule format
of the ``rewrite`` plugin, but also an advanced format:
there, the plugin doesn't consider the value of the rewritten field,
but instead checks if the given item matches a :doc:`query </reference/query>`.
Only then, the field is replaced with the given value.
It's also possible to replace multiple fields at once,
and even supports multi-valued fields.
To use advanced field rewriting, first enable the ``advancedrewrite`` plugin
(see :ref:`using-plugins`).
Then, make a ``advancedrewrite:`` section in your config file to contain
your rewrite rules.
In contrast to the normal ``rewrite`` plugin, you need to provide a list
of replacement rule objects, each consisting of a query, a field name,
and the replacement value.
In contrast to the normal ``rewrite`` plugin, you need to provide a list of
replacement rule objects, which can have a different syntax depending on
the rule complexity.
The simple syntax is the same as the one of the rewrite plugin and allows
to replace a single field::
advancedrewrite:
- artist ODD EYE CIRCLE: 이달의 소녀 오드아이써클
The advanced syntax consists of a query to match against, as well as a map
of replacements to apply.
For example, to credit all songs of ODD EYE CIRCLE before 2023
to their original group name, you can use the following rule::
advancedrewrite:
- match: "mb_artistid:dec0f331-cb08-4c8e-9c9f-aeb1f0f6d88c year:..2022"
field: artist
replacement: "이달의 소녀 오드아이써클"
replacements:
artist: 이달의 소녀 오드아이써클
artist_sort: LOONA / ODD EYE CIRCLE
Note how the sort name is also rewritten within the same rule.
You can specify as many fields as you'd like in the replacements map.
If you need to work with multi-valued fields, you can use the following syntax::
advancedrewrite:
- match: "artist:배유빈 feat. 김미현"
replacements:
artists:
- 유빈
- 미미
As a convenience, the plugin applies patterns for the ``artist`` field to the
``albumartist`` field as well. (Otherwise, you would probably want to duplicate
every rule for ``artist`` and ``albumartist``.)
Make sure to properly quote your query strings if they contain spaces,
otherwise they might not do what you expect, or even cause beets to crash.
Take the following example::
advancedrewrite:
# BAD, DON'T DO THIS!
- match: album:THE ALBUM
replacements:
artist: New artist
On the first sight, this might look sane, and replace the artist of the album
*THE ALBUM* with *New artist*. However, due to the space and missing quotes,
this query will evaluate to ``album:THE`` and match ``ALBUM`` on any field,
including ``artist``. As ``artist`` is the field being replaced,
this query will result in infinite recursion and ultimately crash beets.
Instead, you should use the following rule::
advancedrewrite:
# Note the quotes around the query string!
- match: album:"THE ALBUM"
replacements:
artist: New artist
A word of warning: This plugin theoretically only applies to templates and path
formats; it initially does not modify files' metadata tags or the values
tracked by beets' library database, but since it *rewrites all field lookups*,
it modifies the file's metadata anyway. See comments in issue :bug:`2786`.
As an alternative to this plugin the simpler :doc:`/plugins/rewrite` or
similar :doc:`/plugins/substitute` can be used.
As an alternative to this plugin the simpler but less powerful
:doc:`/plugins/rewrite` can be used.
If you don't want to modify the item's metadata and only replace values
in file paths, you can check out the :doc:`/plugins/substitute`.

View file

@ -1,23 +1,40 @@
MusicBrainz Submit Plugin
=========================
The ``mbsubmit`` plugin provides an extra prompt choice during an import
session and a ``mbsubmit`` command that prints the tracks of the current
album in a format that is parseable by MusicBrainz's `track parser`_.
The ``mbsubmit`` plugin provides extra prompt choices when an import session
fails to find a good enough match for a release. Additionally, it provides an
``mbsubmit`` command that prints the tracks of the current album in a format
that is parseable by MusicBrainz's `track parser`_. The prompt choices are:
- Print the tracks to stdout in a format suitable for MusicBrainz's `track
parser`_.
- Open the program `Picard`_ with the unmatched folder as an input, allowing
you to start submitting the unmatched release to MusicBrainz with many input
fields already filled in, thanks to Picard reading the preexisting tags of
the files.
For the last option, `Picard`_ is assumed to be installed and available on the
machine including a ``picard`` executable. Picard developers list `download
options`_. `other GNU/Linux distributions`_ may distribute Picard via their
package manager as well.
.. _track parser: https://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings
.. _Picard: https://picard.musicbrainz.org/
.. _download options: https://picard.musicbrainz.org/downloads/
.. _other GNU/Linux distributions: https://repology.org/project/picard-tagger/versions
Usage
-----
Enable the ``mbsubmit`` plugin in your configuration (see :ref:`using-plugins`)
and select the ``Print tracks`` choice which is by default displayed when no
strong recommendations are found for the album::
and select one of the options mentioned above. Here the option ``Print tracks``
choice is demonstrated::
No matching release found for 3 tracks.
For help, see: https://beets.readthedocs.org/en/latest/faq.html#nomatch
[U]se as-is, as Tracks, Group albums, Skip, Enter search, enter Id, aBort,
Print tracks? p
Print tracks, Open files with Picard? p
01. An Obscure Track - An Obscure Artist (3:37)
02. Another Obscure Track - An Obscure Artist (2:05)
03. The Third Track - Another Obscure Artist (3:02)
@ -53,6 +70,11 @@ file. The following options are available:
Default: ``medium`` (causing the choice to be displayed for all albums that
have a recommendation of medium strength or lower). Valid values: ``none``,
``low``, ``medium``, ``strong``.
- **picard_path**: The path to the ``picard`` executable. Could be an absolute
path, and if not, ``$PATH`` is consulted. The default value is simply
``picard``. Windows users will have to find and specify the absolute path to
their ``picard.exe``. That would probably be:
``C:\Program Files\MusicBrainz Picard\picard.exe``.
Please note that some values of the ``threshold`` configuration option might
require other ``beets`` command line switches to be enabled in order to work as

View file

@ -115,6 +115,16 @@ other configuration options are:
- **prefix**: Prepend this string to every path in the playlist file. For
example, you could use the URL for a server where the music is stored.
Default: empty string.
- **urlencoded**: URL-encode all paths. Default: ``no``.
- **urlencode**: URL-encode all paths. Default: ``no``.
- **pretend_paths**: When running with ``--pretend``, show the actual file
paths that will be written to the m3u file. Default: ``false``.
- **uri_format**: Template with an ``$id`` placeholder used generate a
playlist item URI, e.g. ``http://beets:8337/item/$id/file``.
When this option is specified, the local path-related options ``prefix``,
``relative_to``, ``forward_slash`` and ``urlencode`` are ignored.
- **output**: Specify the playlist format: m3u|m3u8. Default ``m3u``.
For many configuration options, there is a corresponding CLI option, e.g.
``--playlist-dir``, ``--relative-to``, ``--prefix``, ``--forward-slash``,
``--urlencode``, ``--uri-format``, ``--output``, ``--pretend-paths``.
CLI options take precedence over those specified within the configuration file.

View file

@ -93,8 +93,10 @@ Optional command flags:
* Relatedly, the ``-q`` (quiet) option can help with large imports by
autotagging without ever bothering to ask for user input. Whenever the
normal autotagger mode would ask for confirmation, the quiet mode
pessimistically skips the album. The quiet mode also disables the tagger's
ability to resume interrupted imports.
performs a fallback action that can be configured using the
``quiet_fallback`` configuration or ``--quiet-fallback`` CLI option.
By default it pessimistically ``skip``s the file.
Alternatively, it can be used as is, by configuring ``asis``.
* Speaking of resuming interrupted imports, the tagger will prompt you if it
seems like the last import of the directory was interrupted (by you or by
@ -113,6 +115,15 @@ Optional command flags:
time, when no subdirectories will be skipped. So consider enabling the
``incremental`` configuration option.
* If you don't want to record skipped files during an *incremental* import, use
the ``--incremental-skip-later`` flag which corresponds to the
``incremental_skip_later`` configuration option.
Setting the flag prevents beets from persisting skip decisions during a
non-interactive import so that a user can make a decision regarding
previously skipped files during a subsequent interactive import run.
To record skipped files during incremental import explicitly, use the
``--noincremental-skip-later`` option.
* When beets applies metadata to your music, it will retain the value of any
existing tags that weren't overwritten, and import them into the database. You
may prefer to only use existing metadata for finding matches, and to erase it

View file

@ -0,0 +1,142 @@
# This file is part of beets.
# Copyright 2023, Max Rumpf.
#
# 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 advancedrewrite plugin for various configurations.
"""
import unittest
from test.helper import TestHelper
from beets.ui import UserError
PLUGIN_NAME = "advancedrewrite"
class AdvancedRewritePluginTest(unittest.TestCase, TestHelper):
def setUp(self):
self.setup_beets()
def tearDown(self):
self.unload_plugins()
self.teardown_beets()
def test_simple_rewrite_example(self):
self.config[PLUGIN_NAME] = [
{"artist ODD EYE CIRCLE": "이달의 소녀 오드아이써클"},
]
self.load_plugins(PLUGIN_NAME)
item = self.add_item(
title="Uncover",
artist="ODD EYE CIRCLE",
albumartist="ODD EYE CIRCLE",
album="Mix & Match",
)
self.assertEqual(item.artist, "이달의 소녀 오드아이써클")
def test_advanced_rewrite_example(self):
self.config[PLUGIN_NAME] = [
{
"match": "mb_artistid:dec0f331-cb08-4c8e-9c9f-aeb1f0f6d88c year:..2022",
"replacements": {
"artist": "이달의 소녀 오드아이써클",
"artist_sort": "LOONA / ODD EYE CIRCLE",
},
},
]
self.load_plugins(PLUGIN_NAME)
item_a = self.add_item(
title="Uncover",
artist="ODD EYE CIRCLE",
albumartist="ODD EYE CIRCLE",
artist_sort="ODD EYE CIRCLE",
albumartist_sort="ODD EYE CIRCLE",
album="Mix & Match",
mb_artistid="dec0f331-cb08-4c8e-9c9f-aeb1f0f6d88c",
year=2017,
)
item_b = self.add_item(
title="Air Force One",
artist="ODD EYE CIRCLE",
albumartist="ODD EYE CIRCLE",
artist_sort="ODD EYE CIRCLE",
albumartist_sort="ODD EYE CIRCLE",
album="ODD EYE CIRCLE <Version Up>",
mb_artistid="dec0f331-cb08-4c8e-9c9f-aeb1f0f6d88c",
year=2023,
)
# Assert that all replacements were applied to item_a
self.assertEqual("이달의 소녀 오드아이써클", item_a.artist)
self.assertEqual("LOONA / ODD EYE CIRCLE", item_a.artist_sort)
self.assertEqual("LOONA / ODD EYE CIRCLE", item_a.albumartist_sort)
# Assert that no replacements were applied to item_b
self.assertEqual("ODD EYE CIRCLE", item_b.artist)
def test_advanced_rewrite_example_with_multi_valued_field(self):
self.config[PLUGIN_NAME] = [
{
"match": "artist:배유빈 feat. 김미현",
"replacements": {
"artists": ["유빈", "미미"],
},
},
]
self.load_plugins(PLUGIN_NAME)
item = self.add_item(
artist="배유빈 feat. 김미현",
artists=["배유빈", "김미현"],
)
self.assertEqual(item.artists, ["유빈", "미미"])
def test_fail_when_replacements_empty(self):
self.config[PLUGIN_NAME] = [
{
"match": "artist:A",
"replacements": {},
},
]
with self.assertRaises(
UserError,
msg="Advanced rewrites must have at least one replacement",
):
self.load_plugins(PLUGIN_NAME)
def test_fail_when_rewriting_single_valued_field_with_list(self):
self.config[PLUGIN_NAME] = [
{
"match": "artist:'A & B'",
"replacements": {
"artist": ["C", "D"],
},
},
]
with self.assertRaises(
UserError,
msg="Field artist is not a multi-valued field but a list was given: C, D",
):
self.load_plugins(PLUGIN_NAME)
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == "__main__":
unittest.main(defaultTest="suite")

View file

@ -45,7 +45,7 @@ class MBSubmitPluginTest(
# Manually build the string for comparing the output.
tracklist = (
"Print tracks? "
"Open files with Picard? "
"01. Tag Title 1 - Tag Artist (0:01)\n"
"02. Tag Title 2 - Tag Artist (0:01)"
)
@ -61,7 +61,9 @@ class MBSubmitPluginTest(
self.importer.run()
# Manually build the string for comparing the output.
tracklist = "Print tracks? " "02. Tag Title 2 - Tag Artist (0:01)"
tracklist = (
"Open files with Picard? " "02. Tag Title 2 - Tag Artist (0:01)"
)
self.assertIn(tracklist, output.getvalue())

View file

@ -19,7 +19,7 @@ from shutil import rmtree
from tempfile import mkdtemp
from test import _common
from test.helper import TestHelper
from unittest.mock import MagicMock, Mock
from unittest.mock import MagicMock, Mock, PropertyMock
from beets import config
from beets.dbcore import OrQuery
@ -191,6 +191,101 @@ class SmartPlaylistTest(_common.TestCase):
self.assertEqual(content, b"/tagada.mp3\n")
def test_playlist_update_output_m3u8(self):
spl = SmartPlaylistPlugin()
i = MagicMock()
type(i).artist = PropertyMock(return_value="fake artist")
type(i).title = PropertyMock(return_value="fake title")
type(i).length = PropertyMock(return_value=300.123)
type(i).path = PropertyMock(return_value=b"/tagada.mp3")
i.evaluate_template.side_effect = lambda pl, _: pl.replace(
b"$title",
b"ta:ga:da",
).decode()
lib = Mock()
lib.replacements = CHAR_REPLACE
lib.items.return_value = [i]
lib.albums.return_value = []
q = Mock()
a_q = Mock()
pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None)
spl._matched_playlists = [pl]
dir = bytestring_path(mkdtemp())
config["smartplaylist"]["output"] = "m3u8"
config["smartplaylist"]["prefix"] = "http://beets:8337/files"
config["smartplaylist"]["relative_to"] = False
config["smartplaylist"]["playlist_dir"] = py3_path(dir)
try:
spl.update_playlists(lib)
except Exception:
rmtree(syspath(dir))
raise
lib.items.assert_called_once_with(q, None)
lib.albums.assert_called_once_with(a_q, None)
m3u_filepath = path.join(dir, b"ta_ga_da-my_playlist_.m3u")
self.assertExists(m3u_filepath)
with open(syspath(m3u_filepath), "rb") as f:
content = f.read()
rmtree(syspath(dir))
self.assertEqual(
content,
b"#EXTM3U\n"
+ b"#EXTINF:300,fake artist - fake title\n"
+ b"http://beets:8337/files/tagada.mp3\n",
)
def test_playlist_update_uri_format(self):
spl = SmartPlaylistPlugin()
i = MagicMock()
type(i).id = PropertyMock(return_value=3)
type(i).path = PropertyMock(return_value=b"/tagada.mp3")
i.evaluate_template.side_effect = lambda pl, _: pl.replace(
b"$title", b"ta:ga:da"
).decode()
lib = Mock()
lib.replacements = CHAR_REPLACE
lib.items.return_value = [i]
lib.albums.return_value = []
q = Mock()
a_q = Mock()
pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None)
spl._matched_playlists = [pl]
dir = bytestring_path(mkdtemp())
tpl = "http://beets:8337/item/$id/file"
config["smartplaylist"]["uri_format"] = tpl
config["smartplaylist"]["playlist_dir"] = py3_path(dir)
# The following options should be ignored when uri_format is set
config["smartplaylist"]["relative_to"] = "/data"
config["smartplaylist"]["prefix"] = "/prefix"
config["smartplaylist"]["urlencode"] = True
try:
spl.update_playlists(lib)
except Exception:
rmtree(syspath(dir))
raise
lib.items.assert_called_once_with(q, None)
lib.albums.assert_called_once_with(a_q, None)
m3u_filepath = path.join(dir, b"ta_ga_da-my_playlist_.m3u")
self.assertExists(m3u_filepath)
with open(syspath(m3u_filepath), "rb") as f:
content = f.read()
rmtree(syspath(dir))
self.assertEqual(content, b"http://beets:8337/item/3/file\n")
class SmartPlaylistCLITest(_common.TestCase, TestHelper):
def setUp(self):