Repository: beetbox/beets

Issue:
Bring back NO_COLOR support?
Back in 2019 I added support for the [NO_COLOR environment variable](https://no-color.org/). This was removed in be290e54441987fdb80437f7982f4d5ad01bac59 as part of a refactor. Unless I'm mistaken it seems there's no way to disable colors from the command line anymore. It must be done in the config file. That's really inconvenient.

What was the reason for removing `NO_COLOR` support? Could it be brought back?


Comments:

Patch:
diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py
index 5e445613..abd7aa0a 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 63f25fca..26eb5320 100755
--- a/beets/ui/commands.py
+++ b/beets/ui/commands.py
@@ -1506,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/smartplaylist.py b/beetsplug/smartplaylist.py
index 6e20cc21..c892a604 100644
--- a/beetsplug/smartplaylist.py
+++ b/beetsplug/smartplaylist.py
@@ -49,6 +49,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
                 "prefix": "",
                 "urlencode": False,
                 "pretend_paths": False,
+                "extm3u": False,
             }
         )
 
@@ -71,6 +72,17 @@ class SmartPlaylistPlugin(BeetsPlugin):
             action="store_true",
             help="display query results but don't write playlist files.",
         )
+        spl_update.parser.add_option(
+            "--extm3u",
+            action="store_true",
+            help="add artist/title as m3u8 comments to playlists.",
+        )
+        spl_update.parser.add_option(
+            "--no-extm3u",
+            action="store_false",
+            dest="extm3u",
+            help="do not add artist/title as extm3u comments to playlists.",
+        )
         spl_update.func = self.update_cmd
         return [spl_update]
 
@@ -99,7 +111,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
         else:
             self._matched_playlists = self._unmatched_playlists
 
-        self.update_playlists(lib, opts.pretend)
+        self.update_playlists(lib, opts.extm3u, opts.pretend)
 
     def build_queries(self):
         """
@@ -185,7 +197,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
 
         self._unmatched_playlists -= self._matched_playlists
 
-    def update_playlists(self, lib, pretend=False):
+    def update_playlists(self, lib, extm3u=None, pretend=False):
         if pretend:
             self._log.info(
                 "Showing query results for {0} smart playlists...",
@@ -230,7 +242,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
                 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)
+                    m3us[m3u_name].append({"item": item, "path": item_path})
                     if pretend and self.config["pretend_paths"]:
                         print(displayable_path(item_path))
                     elif pretend:
@@ -244,13 +256,23 @@ class SmartPlaylistPlugin(BeetsPlugin):
                     os.path.join(playlist_dir, bytestring_path(m3u))
                 )
                 mkdirall(m3u_path)
+                extm3u = extm3u is None and self.config["extm3u"] or extm3u
                 with open(syspath(m3u_path), "wb") as f:
-                    for path in m3us[m3u]:
+                    if extm3u:
+                        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))
-                        f.write(prefix + path + b"\n")
+                        comment = ""
+                        if extm3u:
+                            comment = "#EXTINF:{},{} - {}\n".format(
+                                int(item.length), item.artist, item.title
+                            )
+                        f.write(comment.encode("utf-8") + prefix + path + b"\n")
             # Send an event when playlists were updated.
             send_event("smartplaylist_update")
 
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 23342329..d7530379 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -13,7 +13,7 @@ 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:
 
@@ -147,6 +147,8 @@ New features:
 * :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`: Add new config option `smartplaylist.extm3u`.
 
 Bug fixes:
 
@@ -267,8 +269,10 @@ 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.
 
diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst
index e687a68a..6a78124e 100644
--- a/docs/plugins/smartplaylist.rst
+++ b/docs/plugins/smartplaylist.rst
@@ -118,3 +118,4 @@ other configuration options are:
 - **urlencoded**: 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``.
+- **extm3u**: Generate extm3u/m3u8 playlists. Default ``ǹo``.
diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst
index a2997c70..8caf7076 100644
--- a/docs/reference/cli.rst
+++ b/docs/reference/cli.rst
@@ -115,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


Test patch:
diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py
index a3a03b54..f3660126 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,56 @@ class SmartPlaylistTest(_common.TestCase):
 
         self.assertEqual(content, b"/tagada.mp3\n")
 
+    def test_playlist_update_extm3u(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"]["extm3u"] = True
+        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",
+        )
+
 
 class SmartPlaylistCLITest(_common.TestCase, TestHelper):
     def setUp(self):