diff --git a/beets/plugins.py b/beets/plugins.py index 270da9751..e1ac7d618 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -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 diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 5e445613f..abd7aa0a5 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -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. diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 439858477..26eb5320a 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -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", diff --git a/beetsplug/advancedrewrite.py b/beetsplug/advancedrewrite.py index fbb455314..639aa5247 100644 --- a/beetsplug/advancedrewrite.py +++ b/beetsplug/advancedrewrite.py @@ -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 diff --git a/beetsplug/mbsubmit.py b/beetsplug/mbsubmit.py index e4c0f372e..d215e616c 100644 --- a/beetsplug/mbsubmit.py +++ b/beetsplug/mbsubmit.py @@ -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): diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 6e20cc21b..12a1c9218 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -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") diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 24461194c..a825ef35a 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -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( diff --git a/docs/changelog.rst b/docs/changelog.rst index a7a4ae9e9..73cc10f3d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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. diff --git a/docs/plugins/advancedrewrite.rst b/docs/plugins/advancedrewrite.rst index 8ac0e277e..e244be44b 100644 --- a/docs/plugins/advancedrewrite.rst +++ b/docs/plugins/advancedrewrite.rst @@ -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 `. 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`. diff --git a/docs/plugins/mbsubmit.rst b/docs/plugins/mbsubmit.rst index 5cb9be8f1..0e86ddc69 100644 --- a/docs/plugins/mbsubmit.rst +++ b/docs/plugins/mbsubmit.rst @@ -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 diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst index e687a68a4..a40d18882 100644 --- a/docs/plugins/smartplaylist.rst +++ b/docs/plugins/smartplaylist.rst @@ -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. diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 86c615dbb..8caf70763 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -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 diff --git a/test/plugins/test_advancedrewrite.py b/test/plugins/test_advancedrewrite.py new file mode 100644 index 000000000..74d2e5db0 --- /dev/null +++ b/test/plugins/test_advancedrewrite.py @@ -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 ", + 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") diff --git a/test/plugins/test_mbsubmit.py b/test/plugins/test_mbsubmit.py index 6f9c81c04..e495a73a9 100644 --- a/test/plugins/test_mbsubmit.py +++ b/test/plugins/test_mbsubmit.py @@ -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()) diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index a3a03b54c..921ae815e 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -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.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.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):