Repository: beetbox/beets

Issue:
beetsplug: only one plugin can set `template_fields` per field
### Problem

I ran into this when using the new `advancedrewrite` plugin on my production system together with the old `rewrite` plugin.
When using these two (or any two other plugins modifying `BeetsPlugin.template_fields`) together and specifying rules that both target the same field (e.g., `artist`), only the rules of one plugin concerning this field will be applied. Even worse, which plugin will be used depends on the plugin loading order (the one loaded last will be used), which seems to be random-ish: in the importer, the `rewrite` plugin is loaded last, when just invoking `beet`, the `advancedrewrite` plugin is loaded last. The rules for that specific field from the previously loaded plugin(s) are silently dropped.

### Investigation

I've pinned this bug down to the following line: [`beets.plugins.item_field_getters`](https://github.com/beetbox/beets/blob/36454a3883c9e5c656805152d2220e9496a7455c/beets/plugins.py#L454). The reason this breaks is that the `update` function on the `dict` overwrites previous getters, instead of merging them somehow.
Of course, the problem lies in the fact that rewrites can generally affect each other and do thus need a deterministic way of applying them.

### Suggested solution (implemented in #5003)

I suggest addressing this issue the following way:

* Rework the getter functions applied to `template_fields` or `album_template_fields` so that they either return a replacement value **or `None`** (possibly breaking change for 3rd-party plugins).
* Merge getter functions into a dict with lists of functions in `plugins.item_field_getters`.
* When evaluating the getters in `beets.dbcore.Model._get`, the **list is iterated and the value of the first value that isn't `None` is used**.
* To make sure the value that's used is actually deterministic, the getters can be sorted by the class names of the plugin providing them (alphabetically). In my opinion, that's not ideal, but it's at least consistent.

### Setup

* OS: Ubuntu Server 22.04
* Python version: 3.10
* beets version: 1.6.1-master (2023-11-23)
* Turning off plugins made problem go away: not applicable, bug specifically affects plugin handling

Comments:

Patch:
diff --git a/beets/plugins.py b/beets/plugins.py
index 270da975..e1ac7d61 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/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py
index 120361d3..12a1c921 100644
--- a/beetsplug/smartplaylist.py
+++ b/beetsplug/smartplaylist.py
@@ -45,6 +45,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
                 "playlist_dir": ".",
                 "auto": True,
                 "playlists": [],
+                "uri_format": None,
                 "forward_slash": False,
                 "prefix": "",
                 "urlencode": False,
@@ -109,6 +110,12 @@ class SmartPlaylistPlugin(BeetsPlugin):
             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",
@@ -247,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)
@@ -275,18 +284,26 @@ 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": item, "path": 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(
@@ -303,18 +320,13 @@ class SmartPlaylistPlugin(BeetsPlugin):
                     if m3u8:
                         f.write(b"#EXTM3U\n")
                     for entry in m3us[m3u]:
-                        path = entry["path"]
                         item = entry["item"]
-                        if self.config["forward_slash"].get():
-                            path = path_as_posix(path)
-                        if self.config["urlencode"]:
-                            path = bytestring_path(pathname2url(path))
                         comment = ""
                         if m3u8:
                             comment = "#EXTINF:{},{} - {}\n".format(
                                 int(item.length), item.artist, item.title
                             )
-                        f.write(comment.encode("utf-8") + prefix + path + b"\n")
+                        f.write(comment.encode("utf-8") + entry["uri"] + b"\n")
             # Send an event when playlists were updated.
             send_event("smartplaylist_update")
 
diff --git a/docs/changelog.rst b/docs/changelog.rst
index c88a1009..86dc97b0 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -148,8 +148,9 @@ New features:
   `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`: Add new config option `smartplaylist.output`.
 * :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`.
 
 Bug fixes:
 
@@ -277,6 +278,15 @@ Bug fixes:
 * 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/smartplaylist.rst b/docs/plugins/smartplaylist.rst
index 365b5af3..a40d1888 100644
--- a/docs/plugins/smartplaylist.rst
+++ b/docs/plugins/smartplaylist.rst
@@ -118,9 +118,13 @@ other configuration options are:
 - **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``, ``--output``, ``--pretend-paths``.
+``--urlencode``, ``--uri-format``, ``--output``, ``--pretend-paths``.
 CLI options take precedence over those specified within the configuration file.


Test patch:
diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py
index 96eac625..921ae815 100644
--- a/test/plugins/test_smartplaylist.py
+++ b/test/plugins/test_smartplaylist.py
@@ -241,6 +241,51 @@ class SmartPlaylistTest(_common.TestCase):
             + 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):