2525import codecs
2626import os
2727import traceback
28+ from collections import defaultdict
2829from typing import Union
2930
3031import pylast
3132import yaml
3233
3334from beets import config , library , plugins , ui
3435from beets .library import Album , Item
36+ from beets .ui import UserError
3537from beets .util import normpath , plurality , unique_list
3638
3739LASTFM = pylast .LastFMNetwork (api_key = plugins .LASTFM_KEY )
@@ -105,6 +107,7 @@ def __init__(self):
105107 "prefer_specific" : False ,
106108 "title_case" : True ,
107109 "extended_debug" : False ,
110+ "blacklist" : False ,
108111 }
109112 )
110113 self .setup ()
@@ -117,6 +120,7 @@ def setup(self):
117120 self ._genre_cache = {}
118121 self .whitelist = self ._load_whitelist ()
119122 self .c14n_branches , self .canonicalize = self ._load_c14n_tree ()
123+ self .blacklist = self ._load_blacklist ()
120124
121125 def _load_whitelist (self ) -> set [str ]:
122126 whitelist = set ()
@@ -151,6 +155,54 @@ def _load_c14n_tree(self) -> tuple[list[list[str]], bool]:
151155 flatten_tree (genres_tree , [], c14n_branches )
152156 return c14n_branches , canonicalize
153157
158+ def _load_blacklist (self ) -> defaultdict [list [str ]]:
159+ """Load the blacklist from a configured file path.
160+
161+ For maximum compatibility with regex patterns, a custom format is used:
162+ - Each section starts with an artist name, followed by a colon.
163+ - Subsequent lines are indented and contain a regex pattern to match a genre.
164+
165+ Eg.:
166+ artist name 1:
167+ - genre pattern 1
168+ - genre pattern 2
169+ artist name 2:
170+ - genre pattern 3
171+
172+ Raises:
173+ UserError: if the file format is invalid.
174+ """
175+ blacklist = defaultdict (list )
176+ if not (bl_filename := self .config ["blacklist" ].get ()):
177+ return blacklist
178+ if not os .path .isfile (bl_filename := normpath (bl_filename )):
179+ self ._log .error ("Blacklist file not found: {} ." , bl_filename )
180+ return blacklist
181+
182+ current = None
183+ with open (bl_filename , "rb" ) as f :
184+ for lineno , line in enumerate (f , 1 ):
185+ line = line .strip ()
186+ if not line or line .startswith (b"#" ):
187+ continue
188+ if not line .startswith (b' ' ):
189+ if not line .endswith (b':' ):
190+ raise UserError (
191+ f"Malformed blacklist section header (eg 'artist 1:') "
192+ f"at line { lineno } : { line .decode ('utf-8' , 'replace' )} "
193+ )
194+ current = line .rstrip (b':' ).decode ("utf-8" , "replace" )
195+ else :
196+ if current is None :
197+ raise UserError (
198+ f"Blacklist regex pattern line before any section header "
199+ f"at line { lineno } : { line .decode ('utf-8' , 'replace' )} "
200+ )
201+ blacklist [current ].append (
202+ line .strip ().decode ("utf-8" , "replace" )
203+ )
204+ return blacklist
205+
154206 @property
155207 def sources (self ) -> tuple [str , ...]:
156208 """A tuple of allowed genre sources. May contain 'track',
0 commit comments