Split artist credits with documented exceptions and normalize artist names

This commit is contained in:
2026-08-04 15:15:49 +08:00
parent 6a91bc73eb
commit ae0e9d0a08
3 changed files with 369 additions and 23 deletions
+8
View File
@@ -133,3 +133,11 @@
因署名字串寫法不同而分裂者,以明示對照表合併;歌曲身分= 因署名字串寫法不同而分裂者,以明示對照表合併;歌曲身分=
(原始歌名,對照後署名字串),不解析署名——拆解機器不進 (原始歌名,對照後署名字串),不解析署名——拆解機器不進
歌曲身分判定,對照表即完整可稽核清單。 歌曲身分判定,對照表即完整可稽核清單。
- **歌手名單拆解規則定案**:署名列出成員的團體只留成員
(冒號與括號兩型);名字內含連接詞的單一藝人入保護名單;
規則無法表達的個案入例外表;人名連寫的二人組拆成個人;
僅以團名掛名的團體維持單一實體,屬性留待 fetch-artists
與人工 overrides。完整清單見 `build_db.py`
- **歌手名正規化**:歌手名大小寫不敏感歸併、存首見拼法;
正名表(casefold 鍵→官方拼法)存官方拼法,含跨拼法合併
ye→Kanye West。
+149 -21
View File
@@ -29,7 +29,15 @@ credit, the credit canonicalized through
``CANONICAL_ARTIST_CREDITS``; a credit listed there collapses onto ``CANONICAL_ARTIST_CREDITS``; a credit listed there collapses onto
the same song as its canonical form, and the stored artist credit the same song as its canonical form, and the stored artist credit
is always the canonical form. Artist deduplication is by the is always the canonical form. Artist deduplication is by the
exact parsed artist name, as printed on the chart. identity key resolved from the parsed artist name (see
`resolve_artist_identity`): the case-folded name, or, when that
case-folded name is listed in ``CANONICAL_ARTIST_NAMES``, the
case-folded canonical spelling, so letter-case variants and
alternate spellings mapped to the same canonical name all
collapse onto a single artist row. The stored artist name is the
first-seen spelling, except for the names listed in
``CANONICAL_ARTIST_NAMES``, which always store the canonical
spelling regardless of which variant is seen first.
""" """
import argparse import argparse
import csv import csv
@@ -68,13 +76,67 @@ FEATURING_PATTERN: re.Pattern[str] = re.compile(
r" featuring | feat\. ", re.IGNORECASE) r" featuring | feat\. ", re.IGNORECASE)
"""The pattern splitting the primary and featured sides.""" """The pattern splitting the primary and featured sides."""
DELIMITER_PATTERN: re.Pattern[str] = re.compile( DELIMITER_PATTERN: re.Pattern[str] = re.compile(
r", | & | \+ |(?i: and | x | with )") r", | & | \+ | / |(?i: and | x | with )")
"""The pattern splitting the artist names within a side.""" """The pattern splitting the artist names within a side."""
COLON_PATTERN: re.Pattern[str] = re.compile(r": ")
"""The pattern separating a group prefix from its members in a
"<group>: <members>" credit."""
PAREN_MEMBERS_PATTERN: re.Pattern[str] = re.compile(
r"^.+ \((?P<members>.+)\)$")
"""The pattern separating a group name from its members in a
"<group> (<members>)" credit spanning the whole credit."""
DUET_WITH_PATTERN: re.Pattern[str] = re.compile(
r" Duet With ", re.IGNORECASE)
"""The pattern normalizing the "Duet With" co-billing connector
to the plain "with" delimiter."""
PROTECTED_ARTIST_NAMES: tuple[str, ...] = (
"Tyler, The Creator",
"Lil Nas X",
"Tones And I",
)
"""The exact artist names guarded from the delimiter splitting,
because each contains a delimiter word or punctuation as part of
the name itself."""
EXCEPTION_CREDITS: dict[str, list[tuple[str, Role]]] = {
"SpotemGottem Featuring Pooh Shiesty Or DaBaby": [
("SpotemGottem", Role.PRIMARY),
("Pooh Shiesty", Role.FEATURED),
("DaBaby", Role.FEATURED),
],
"THE SCOTTS, Travis Scott & Kid Cudi": [
("Travis Scott", Role.PRIMARY),
("Kid Cudi", Role.PRIMARY),
],
"Drake Featuring The Throne": [
("Drake", Role.PRIMARY),
("Jay Z", Role.FEATURED),
("Kanye West", Role.FEATURED),
],
}
"""The single-credit exceptions parsed by an explicit lookup
rather than by the general rules, because the credit text alone
does not spell out the correct member split."""
CANONICAL_ARTIST_CREDITS: dict[str, str] = { CANONICAL_ARTIST_CREDITS: dict[str, str] = {
"benny blanco, Halsey & Khalid": "Benny Blanco, Halsey & Khalid", "benny blanco, Halsey & Khalid": "Benny Blanco, Halsey & Khalid",
} }
"""The canonical artist credit spellings, keyed by a variant """The canonical artist credit spellings, keyed by a variant
credit string.""" credit string."""
CANONICAL_ARTIST_NAMES: dict[str, str] = {
"beyonce": "Beyoncé",
"5 seconds of summer": "5 Seconds of Summer",
"a boogie wit da hoodie": "A Boogie wit da Hoodie",
"benny blanco": "benny blanco",
"blackbear": "blackbear",
"chance the rapper": "Chance the Rapper",
"xxxtentacion": "XXXTENTACION",
"maneskin": "Måneskin",
"rose": "ROSÉ",
"mo": "",
"wizkid": "Wizkid",
"ye": "Kanye West",
}
"""The canonical artist spellings, keyed by the case-folded
identity."""
class BuildError(Exception): class BuildError(Exception):
@@ -109,22 +171,55 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
def parse_artist_credit(credit: str) -> list[tuple[str, Role]]: def parse_artist_credit(credit: str) -> list[tuple[str, Role]]:
"""Parse a combined artist credit into artists and roles. """Parse a combined artist credit into artists and roles.
The credit splits into a primary side and a featured side on A credit listed in ``EXCEPTION_CREDITS`` is looked up verbatim,
the word "featuring" or "feat.", case-insensitively; without because its correct split is not derivable from the credit
them, every artist is primary. Each side splits into artist text alone. Otherwise the credit first reduces to an
names on the delimiters ", ", " & ", " + " (literally) and effective credit: a "<group>: <members>" prefix (split at the
" and ", " x ", " with " (case-insensitively). first ": ") drops the group and keeps the members; failing
that, a "<group> (<members>)" suffix spanning the whole credit
drops the group and keeps the members. The "Duet With"
connector, case-insensitively, then normalizes to "with". The
effective credit splits into a primary side and a featured
side on the word "featuring" or "feat.", case-insensitively;
without them, every artist is primary. Each side splits into
artist names on the delimiters ", ", " & ", " + ", " / "
(literally) and " and ", " x ", " with " (case-insensitively),
except for the names listed in ``PROTECTED_ARTIST_NAMES``,
which are never split even though each contains a delimiter
word or punctuation.
Known limitation: a compound act name that contains one of the Known limitation: a compound act name that contains one of the
delimiters is over-split; such cases are corrected later via delimiters, other than the protected names, is over-split;
the human override layer. such cases are corrected later via the human override layer.
:param credit: The combined artist credit string. :param credit: The combined artist credit string.
:return: The (name, role) pairs in credit order, primary side :return: The (name, role) pairs in credit order, primary side
first, with the role ``Role.PRIMARY`` or first, with the role ``Role.PRIMARY`` or
``Role.FEATURED``. ``Role.FEATURED``.
""" """
sides: list[str] = FEATURING_PATTERN.split(credit, maxsplit=1) if credit in EXCEPTION_CREDITS:
return list(EXCEPTION_CREDITS[credit])
effective: str = credit
colon_match: re.Match[str] | None = COLON_PATTERN.search(
effective)
if colon_match is not None:
effective = effective[colon_match.end():]
else:
paren_match: re.Match[str] | None = \
PAREN_MEMBERS_PATTERN.match(effective)
if paren_match is not None:
effective = paren_match.group("members")
effective = DUET_WITH_PATTERN.sub(" with ", effective)
placeholders: dict[str, str] = {}
index: int
protected: str
for index, protected in enumerate(PROTECTED_ARTIST_NAMES):
if protected in effective:
placeholder: str = f"{index}"
placeholders[placeholder] = protected
effective = effective.replace(protected, placeholder)
sides: list[str] = FEATURING_PATTERN.split(
effective, maxsplit=1)
pairs: list[tuple[str, Role]] = [] pairs: list[tuple[str, Role]] = []
role: Role role: Role
side: str side: str
@@ -132,6 +227,10 @@ def parse_artist_credit(credit: str) -> list[tuple[str, Role]]:
token: str token: str
for token in DELIMITER_PATTERN.split(side): for token in DELIMITER_PATTERN.split(side):
name: str = token.strip() name: str = token.strip()
placeholder = ""
original: str
for placeholder, original in placeholders.items():
name = name.replace(placeholder, original)
if name != "": if name != "":
pairs.append((name, role)) pairs.append((name, role))
return pairs return pairs
@@ -153,21 +252,46 @@ def song_identity(title: str, credit: str) -> tuple[str, str]:
return title, CANONICAL_ARTIST_CREDITS.get(credit, credit) return title, CANONICAL_ARTIST_CREDITS.get(credit, credit)
def resolve_artist_identity(name: str) -> tuple[str, str]:
"""Resolve the dedup key and the stored spelling of a name.
The name's case-folded form is looked up in
``CANONICAL_ARTIST_NAMES`` first; when it is listed there, the
dedup key is the canonical spelling case-folded and the stored
spelling is the canonical spelling, so every variant of the
name, canonical or not, resolves to the same identity.
Otherwise the dedup key is the name case-folded and the stored
spelling is the given name.
:param name: An artist name, as parsed from a credit.
:return: A tuple of the dedup key and the stored spelling.
"""
folded: str = name.casefold()
canonical: str | None = CANONICAL_ARTIST_NAMES.get(folded)
if canonical is not None:
return canonical.casefold(), canonical
return folded, name
def create_song(session: Session, song_id: int, title: str, def create_song(session: Session, song_id: int, title: str,
credit: str, artists: dict[str, Artist]) -> Song: credit: str, artists: dict[str, Artist]) -> Song:
"""Create a song with its parsed artist credits. """Create a song with its parsed artist credits.
The song takes the given ID. A newly created artist takes The song takes the given ID. An artist parsed out of the
the ID following the known artists, in credit order. An credit is matched against the known artists by its identity
artist duplicated within the credit is kept only at its key (see `resolve_artist_identity`); a newly seen one takes
first occurrence, with a warning to the standard error. the ID following the known artists, keyed by its identity key,
and its stored name is the resolved stored spelling. An
artist duplicated within the credit, by its identity key, is
kept only at its first occurrence, with a warning to the
standard error.
:param session: The database session. :param session: The database session.
:param song_id: The song ID to assign. :param song_id: The song ID to assign.
:param title: The song title. :param title: The song title.
:param credit: The combined artist credit string. :param credit: The combined artist credit string.
:param artists: The known artists by name, updated with the :param artists: The known artists by identity key, updated
newly created ones as an observable side effect. with the newly created ones as an observable side effect.
:return: The created song, added to the session. :return: The created song, added to the session.
""" """
song: Song = Song(id=song_id, title=title, song: Song = Song(id=song_id, title=title,
@@ -178,14 +302,18 @@ def create_song(session: Session, song_id: int, title: str,
name: str name: str
role: Role role: Role
for name, role in parse_artist_credit(credit): for name, role in parse_artist_credit(credit):
if name in seen: key: str
stored_name: str
key, stored_name = resolve_artist_identity(name)
if key in seen:
print(f"warning: {credit}: duplicated artist" print(f"warning: {credit}: duplicated artist"
f" \"{name}\"", file=sys.stderr) f" \"{name}\"", file=sys.stderr)
continue continue
seen.add(name) seen.add(key)
if name not in artists: if key not in artists:
artists[name] = Artist(id=len(artists) + 1, name=name) artists[key] = Artist(id=len(artists) + 1,
session.add(SongArtist(song=song, artist=artists[name], name=stored_name)
session.add(SongArtist(song=song, artist=artists[key],
role=role, position=position)) role=role, position=position))
position += 1 position += 1
return song return song
+212 -2
View File
@@ -90,6 +90,150 @@ class TestParseArtistCredit(unittest.TestCase):
[("24kGoldn", Role.PRIMARY), [("24kGoldn", Role.PRIMARY),
("iann dior", Role.FEATURED)]) ("iann dior", Role.FEATURED)])
def test_colon_prefix_group(self) -> None:
"""Test that a colon-prefixed group name is dropped."""
self.assertEqual(
build_db.parse_artist_credit(
"¥$: Ye & Ty Dolla $ign Featuring Rich The Kid"
" & Playboi Carti"),
[("Ye", Role.PRIMARY),
("Ty Dolla $ign", Role.PRIMARY),
("Rich The Kid", Role.FEATURED),
("Playboi Carti", Role.FEATURED)])
def test_colon_prefix_with_ampersand_in_prefix(self) -> None:
"""Test a colon prefix that itself contains "&"."""
self.assertEqual(
build_db.parse_artist_credit(
"Rumi & JINU: EJAE & Andrew Choi"),
[("EJAE", Role.PRIMARY),
("Andrew Choi", Role.PRIMARY)])
def test_colon_prefix_comma_list(self) -> None:
"""Test a colon-prefixed comma-separated member list."""
self.assertEqual(
build_db.parse_artist_credit(
"HUNTR/X: EJAE, Audrey Nuna & REI AMI"),
[("EJAE", Role.PRIMARY),
("Audrey Nuna", Role.PRIMARY),
("REI AMI", Role.PRIMARY)])
def test_colon_prefix_five_members(self) -> None:
"""Test a colon-prefixed five-member list."""
self.assertEqual(
build_db.parse_artist_credit(
"Saja Boys: Andrew Choi, Neckwav, Danny Chung,"
" Kevin Woo & samUIL Lee"),
[("Andrew Choi", Role.PRIMARY),
("Neckwav", Role.PRIMARY),
("Danny Chung", Role.PRIMARY),
("Kevin Woo", Role.PRIMARY),
("samUIL Lee", Role.PRIMARY)])
def test_colon_prefix_duo(self) -> None:
"""Test a colon-prefixed two-member list."""
self.assertEqual(
build_db.parse_artist_credit(
"THE ANXIETY: WILLOW & Tyler Cole"),
[("WILLOW", Role.PRIMARY),
("Tyler Cole", Role.PRIMARY)])
def test_parenthesized_members(self) -> None:
"""Test that a parenthesized member list replaces the
group name spanning the whole credit."""
self.assertEqual(
build_db.parse_artist_credit(
"Silk Sonic (Bruno Mars & Anderson .Paak)"),
[("Bruno Mars", Role.PRIMARY),
("Anderson .Paak", Role.PRIMARY)])
def test_duet_with_connector(self) -> None:
"""Test that "Duet With" is a co-billing connector like
"with", dropping the word "Duet" entirely."""
self.assertEqual(
build_db.parse_artist_credit(
"Blake Shelton Duet With Gwen Stefani"),
[("Blake Shelton", Role.PRIMARY),
("Gwen Stefani", Role.PRIMARY)])
self.assertEqual(
build_db.parse_artist_credit(
"Keith Urban Duet With P!nk"),
[("Keith Urban", Role.PRIMARY),
("P!nk", Role.PRIMARY)])
def test_slash_delimiter(self) -> None:
"""Test the " / " co-billing delimiter."""
self.assertEqual(
build_db.parse_artist_credit(
"Cole Swindell / Lainey Wilson"),
[("Cole Swindell", Role.PRIMARY),
("Lainey Wilson", Role.PRIMARY)])
self.assertEqual(
build_db.parse_artist_credit("Zayn / Taylor Swift"),
[("Zayn", Role.PRIMARY),
("Taylor Swift", Role.PRIMARY)])
def test_protected_name_lil_nas_x(self) -> None:
"""Test that "Lil Nas X" is guarded from the " x "
delimiter split."""
self.assertEqual(
build_db.parse_artist_credit("Lil Nas X & Jack Harlow"),
[("Lil Nas X", Role.PRIMARY),
("Jack Harlow", Role.PRIMARY)])
def test_protected_name_tyler_the_creator(self) -> None:
"""Test that "Tyler, The Creator" is guarded from the
comma delimiter split."""
self.assertEqual(
build_db.parse_artist_credit(
"Tyler, The Creator Featuring GloRilla, Sexyy Red"
" & Lil Wayne"),
[("Tyler, The Creator", Role.PRIMARY),
("GloRilla", Role.FEATURED),
("Sexyy Red", Role.FEATURED),
("Lil Wayne", Role.FEATURED)])
def test_protected_name_tones_and_i(self) -> None:
"""Test that "Tones And I" is guarded from the " and "
delimiter split."""
self.assertEqual(
build_db.parse_artist_credit("Tones And I"),
[("Tones And I", Role.PRIMARY)])
def test_exception_spotemgottem(self) -> None:
"""Test the SpotemGottem exception-table credit."""
self.assertEqual(
build_db.parse_artist_credit(
"SpotemGottem Featuring Pooh Shiesty Or DaBaby"),
[("SpotemGottem", Role.PRIMARY),
("Pooh Shiesty", Role.FEATURED),
("DaBaby", Role.FEATURED)])
def test_exception_the_scotts(self) -> None:
"""Test the THE SCOTTS exception-table credit."""
self.assertEqual(
build_db.parse_artist_credit(
"THE SCOTTS, Travis Scott & Kid Cudi"),
[("Travis Scott", Role.PRIMARY),
("Kid Cudi", Role.PRIMARY)])
def test_exception_drake_featuring_the_throne(self) -> None:
"""Test the Drake Featuring The Throne exception-table
credit."""
self.assertEqual(
build_db.parse_artist_credit(
"Drake Featuring The Throne"),
[("Drake", Role.PRIMARY),
("Jay Z", Role.FEATURED),
("Kanye West", Role.FEATURED)])
def test_plus_delimiter_unaffected(self) -> None:
"""Test that the existing "+" delimiter split is
unaffected by the new rules."""
self.assertEqual(
build_db.parse_artist_credit("Dan + Shay"),
[("Dan", Role.PRIMARY), ("Shay", Role.PRIMARY)])
class TestBuildDB(unittest.TestCase): class TestBuildDB(unittest.TestCase):
"""Test cases for the working store build.""" """Test cases for the working store build."""
@@ -194,7 +338,8 @@ class TestBuildDB(unittest.TestCase):
def test_dedup_credit_variant(self) -> None: def test_dedup_credit_variant(self) -> None:
"""Test that a credit variant listed in """Test that a credit variant listed in
``CANONICAL_ARTIST_CREDITS`` merges into one song, storing ``CANONICAL_ARTIST_CREDITS`` merges into one song, storing
the canonical credit.""" the canonical credit, with the canonical artist spelling
applied to the artist listed in ``CANONICAL_ARTIST_NAMES``."""
self.__write_chart( self.__write_chart(
"year,rank,title,artist\n" "year,rank,title,artist\n"
"2016,1,Eastside,\"benny blanco, Halsey & Khalid\"\n" "2016,1,Eastside,\"benny blanco, Halsey & Khalid\"\n"
@@ -217,7 +362,72 @@ class TestBuildDB(unittest.TestCase):
[(2016, 1), (2017, 1)]) [(2016, 1), (2017, 1)])
self.assertEqual( self.assertEqual(
[x.artist.name for x in songs[0].song_artists], [x.artist.name for x in songs[0].song_artists],
["Benny Blanco", "Halsey", "Khalid"]) ["benny blanco", "Halsey", "Khalid"])
def test_dedup_artist_name_case_variant(self) -> None:
"""Test that an artist name case variant merges into one
artist, keeping the first-seen spelling."""
self.__write_chart(
"year,rank,title,artist\n"
"2016,1,Song One,Marshmello\n"
"2016,2,filler,Filler Artist\n"
"2017,1,Song Two,marshmello\n"
"2017,2,filler,Filler Artist\n")
status: int
stderr: str
status, stderr = self.__run_build()
self.assertEqual(status, 0)
session: Session = self.__session()
artists: list[Artist] = list(session.scalars(
sa.select(Artist).where(Artist.name.ilike("marsh%"))))
self.assertEqual(len(artists), 1)
self.assertEqual(artists[0].name, "Marshmello")
self.assertEqual(
{x.title for x in
{y.song for y in artists[0].song_artists}},
{"Song One", "Song Two"})
def test_canonical_artist_name_restored(self) -> None:
"""Test that a canonical spelling is stored even for a
single, non-canonically spelled occurrence."""
self.__write_chart(
"year,rank,title,artist\n"
"2016,1,Beggin',Maneskin\n"
"2016,2,filler,Filler Artist\n"
"2017,1,filler2,Filler Artist Two\n"
"2017,2,filler3,Filler Artist Three\n")
status: int
stderr: str
status, stderr = self.__run_build()
self.assertEqual(status, 0)
session: Session = self.__session()
artist: Artist | None = session.scalar(
sa.select(Artist).where(Artist.name.ilike("m%nesk%")))
assert artist is not None
self.assertEqual(artist.name, "Måneskin")
def test_canonical_ye_resolves_to_kanye_west(self) -> None:
"""Test that a "Ye" credit and a "Kanye West" credit merge
into a single artist row named "Kanye West", credited on
both songs."""
self.__write_chart(
"year,rank,title,artist\n"
"2016,1,Song One,Ye\n"
"2016,2,Song Two,Kanye West\n"
"2017,1,filler2,Filler Artist Two\n"
"2017,2,filler3,Filler Artist Three\n")
status: int
stderr: str
status, stderr = self.__run_build()
self.assertEqual(status, 0)
session: Session = self.__session()
artists: list[Artist] = list(session.scalars(
sa.select(Artist).where(Artist.name == "Kanye West")))
self.assertEqual(len(artists), 1)
self.assertEqual(
{x.title for x in
{y.song for y in artists[0].song_artists}},
{"Song One", "Song Two"})
def test_first_run_on_fresh_store(self) -> None: def test_first_run_on_fresh_store(self) -> None:
"""Test that a build on a fresh store creates the tables.""" """Test that a build on a fresh store creates the tables."""