Split artist credits with documented exceptions and normalize artist names
This commit is contained in:
@@ -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": "MØ",
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user