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
+149 -21
View File
@@ -29,7 +29,15 @@ credit, the credit canonicalized through
``CANONICAL_ARTIST_CREDITS``; a credit listed there collapses onto
the same song as its canonical form, and the stored artist credit
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 csv
@@ -68,13 +76,67 @@ FEATURING_PATTERN: re.Pattern[str] = re.compile(
r" featuring | feat\. ", re.IGNORECASE)
"""The pattern splitting the primary and featured sides."""
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."""
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] = {
"benny blanco, Halsey & Khalid": "Benny Blanco, Halsey & Khalid",
}
"""The canonical artist credit spellings, keyed by a variant
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):
@@ -109,22 +171,55 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
def parse_artist_credit(credit: str) -> list[tuple[str, Role]]:
"""Parse a combined artist credit into artists and roles.
The 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).
A credit listed in ``EXCEPTION_CREDITS`` is looked up verbatim,
because its correct split is not derivable from the credit
text alone. Otherwise the credit first reduces to an
effective credit: a "<group>: <members>" prefix (split at the
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
delimiters is over-split; such cases are corrected later via
the human override layer.
delimiters, other than the protected names, is over-split;
such cases are corrected later via the human override layer.
:param credit: The combined artist credit string.
:return: The (name, role) pairs in credit order, primary side
first, with the role ``Role.PRIMARY`` or
``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]] = []
role: Role
side: str
@@ -132,6 +227,10 @@ def parse_artist_credit(credit: str) -> list[tuple[str, Role]]:
token: str
for token in DELIMITER_PATTERN.split(side):
name: str = token.strip()
placeholder = ""
original: str
for placeholder, original in placeholders.items():
name = name.replace(placeholder, original)
if name != "":
pairs.append((name, role))
return pairs
@@ -153,21 +252,46 @@ def song_identity(title: str, credit: str) -> tuple[str, str]:
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,
credit: str, artists: dict[str, Artist]) -> Song:
"""Create a song with its parsed artist credits.
The song takes the given ID. A newly created artist takes
the ID following the known artists, in credit order. An
artist duplicated within the credit is kept only at its
first occurrence, with a warning to the standard error.
The song takes the given ID. An artist parsed out of the
credit is matched against the known artists by its identity
key (see `resolve_artist_identity`); a newly seen one takes
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 song_id: The song ID to assign.
:param title: The song title.
:param credit: The combined artist credit string.
:param artists: The known artists by name, updated with the
newly created ones as an observable side effect.
:param artists: The known artists by identity key, updated
with the newly created ones as an observable side effect.
:return: The created song, added to the session.
"""
song: Song = Song(id=song_id, title=title,
@@ -178,14 +302,18 @@ def create_song(session: Session, song_id: int, title: str,
name: str
role: Role
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"
f" \"{name}\"", file=sys.stderr)
continue
seen.add(name)
if name not in artists:
artists[name] = Artist(id=len(artists) + 1, name=name)
session.add(SongArtist(song=song, artist=artists[name],
seen.add(key)
if key not in artists:
artists[key] = Artist(id=len(artists) + 1,
name=stored_name)
session.add(SongArtist(song=song, artist=artists[key],
role=role, position=position))
position += 1
return song