diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 529bbb2f3..54cf423e1 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -678,3 +678,17 @@ def max_filename_length(path, limit=MAX_FILENAME_LENGTH): return min(res[9], limit) else: return limit + + +def feat_tokens(for_artist=True): + """Return a regular expression that matches phrases like "featuring" + that separate a main artist or a song title from secondary artists. + The `for_artist` option determines whether the regex should be + suitable for matching artist fields (the default) or title fields. + """ + feat_words = ['ft', 'featuring', 'feat', 'feat.', 'ft.'] + if for_artist: + feat_words += ['with', 'vs', 'and', 'con', '&'] + return '(?<=\s)(?:{0})(?=\s)'.format( + '|'.join(re.escape(x) for x in feat_words) + ) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 5de16f69a..e83836e0e 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -16,7 +16,7 @@ """ from beets.plugins import BeetsPlugin from beets import ui -from beets.util import displayable_path +from beets.util import displayable_path, feat_tokens from beets import config import logging import re @@ -30,25 +30,19 @@ def split_on_feat(artist): artist, which is always a string, and the featuring artist, which may be a string or None if none is present. """ - parts = re.split( - r'[fF]t\.|[fF]eaturing|[fF]eat\.|\b[wW]ith\b|&|vs\.|and', - artist, - 1, # Only split on the first "feat". - ) - parts = [s.strip() for s in parts] + # split on the first "feat". + regex = re.compile(feat_tokens(), re.IGNORECASE) + parts = [s.strip() for s in regex.split(artist, 1)] if len(parts) == 1: return parts[0], None else: - return parts + return tuple(parts) def contains_feat(title): """Determine whether the title contains a "featured" marker. """ - return bool(re.search( - r'[fF]t\.|[fF]eaturing|[fF]eat\.|\b[wW]ith\b|&', - title, - )) + return bool(re.search(feat_tokens(), title, flags=re.IGNORECASE)) def update_metadata(item, feat_part, drop_feat): diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 73821ee22..462bf55c0 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -29,6 +29,7 @@ from HTMLParser import HTMLParseError from beets.plugins import BeetsPlugin from beets import ui from beets import config +from beets.util import feat_tokens # Global logger. @@ -137,7 +138,7 @@ def search_pairs(item): artists = [artist] # Remove any featuring artists from the artists name - pattern = r"(.*?) (&|\b(and|ft|feat(uring)?\b))" + pattern = r"(.*?) {0}".format(feat_tokens()) match = re.search(pattern, artist, re.IGNORECASE) if match: artists.append(match.group(1)) @@ -150,8 +151,8 @@ def search_pairs(item): titles.append(match.group(1)) # Remove any featuring artists from the title - pattern = r"(.*?) \b(ft|feat(uring)?)\b" - for title in titles: + pattern = r"(.*?) {0}".format(feat_tokens(for_artist=False)) + for title in titles[:]: match = re.search(pattern, title, re.IGNORECASE) if match: titles.append(match.group(1)) diff --git a/test/test_ftintitle.py b/test/test_ftintitle.py new file mode 100644 index 000000000..77e416c5a --- /dev/null +++ b/test/test_ftintitle.py @@ -0,0 +1,60 @@ +# This file is part of beets. +# Copyright 2014, Fabrice Laporte. +# +# 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. + +"""Tests for the 'ftintitle' plugin.""" + +from _common import unittest +from beetsplug import ftintitle + + +class FtInTitlePluginTest(unittest.TestCase): + def setUp(self): + """Set up configuration""" + ftintitle.FtInTitlePlugin() + + def test_split_on_feat(self): + parts = ftintitle.split_on_feat('Alice ft. Bob') + self.assertEqual(parts, ('Alice', 'Bob')) + parts = ftintitle.split_on_feat('Alice feat Bob') + self.assertEqual(parts, ('Alice', 'Bob')) + parts = ftintitle.split_on_feat('Alice feat. Bob') + self.assertEqual(parts, ('Alice', 'Bob')) + parts = ftintitle.split_on_feat('Alice featuring Bob') + self.assertEqual(parts, ('Alice', 'Bob')) + parts = ftintitle.split_on_feat('Alice & Bob') + self.assertEqual(parts, ('Alice', 'Bob')) + parts = ftintitle.split_on_feat('Alice and Bob') + self.assertEqual(parts, ('Alice', 'Bob')) + parts = ftintitle.split_on_feat('Alice With Bob') + self.assertEqual(parts, ('Alice', 'Bob')) + parts = ftintitle.split_on_feat('Alice defeat Bob') + self.assertEqual(parts, ('Alice defeat Bob', None)) + + def test_contains_feat(self): + self.assertTrue(ftintitle.contains_feat('Alice ft. Bob')) + self.assertTrue(ftintitle.contains_feat('Alice feat. Bob')) + self.assertTrue(ftintitle.contains_feat('Alice feat Bob')) + self.assertTrue(ftintitle.contains_feat('Alice featuring Bob')) + self.assertTrue(ftintitle.contains_feat('Alice & Bob')) + self.assertTrue(ftintitle.contains_feat('Alice and Bob')) + self.assertTrue(ftintitle.contains_feat('Alice With Bob')) + self.assertFalse(ftintitle.contains_feat('Alice defeat Bob')) + self.assertFalse(ftintitle.contains_feat('Aliceft.Bob')) + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == '__main__': + unittest.main(defaultTest='suite')