mirror of
https://github.com/beetbox/beets.git
synced 2026-01-08 00:45:55 +01:00
Merge branch 'master' into master
This commit is contained in:
commit
d927262bd5
15 changed files with 653 additions and 72 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
142
test/plugins/test_advancedrewrite.py
Normal file
142
test/plugins/test_advancedrewrite.py
Normal 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")
|
||||
|
|
@ -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())
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue