Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

advancedrewrite: Support simple syntax and improve advanced syntax #5044

Merged
merged 2 commits into from
Dec 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 126 additions & 29 deletions beetsplug/advancedrewrite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -53,40 +75,115 @@ def __init__(self):
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)]),
),
},
]
)
)

# 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))
if fieldname == "artist":
# Special case for the artist field: apply the same
# rewrite for "albumartist" as well.
simple_rules["albumartist"].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))
# Special case for the artist(s) field:
# apply the same rewrite for "albumartist(s)" as well.
if fieldname == "artist":
advanced_rules["albumartist"].append(
(query, replacement)
)
elif fieldname == "artists":
advanced_rules["albumartists"].append(
(query, replacement)
)
elif fieldname == "artist_sort":
advanced_rules["albumartist_sort"].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
45 changes: 36 additions & 9 deletions docs/plugins/advancedrewrite.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,53 @@ 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
Expand All @@ -35,5 +60,7 @@ 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`.
142 changes: 142 additions & 0 deletions test/plugins/test_advancedrewrite.py
Original file line number Diff line number Diff line change
@@ -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")
Loading