Repackage the build-db import steps into job classes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -195,6 +195,19 @@
|
|||||||
查詢,API 順序不變。理由:二重唱/雙掛名歌曲在歌詞 API
|
查詢,API 順序不變。理由:二重唱/雙掛名歌曲在歌詞 API
|
||||||
目錄以合體名義建檔(Dan + Shay、Lil Baby & DaBaby),
|
目錄以合體名義建檔(Dan + Shay、Lil Baby & DaBaby),
|
||||||
單人名查詢必落空;手動模擬證實 fallback 三首全中。
|
單人名查詢必落空;手動模擬證實 fallback 三首全中。
|
||||||
|
- **`build-db` 匯入重構為兩個 job class**:`SongImporter`
|
||||||
|
(榜單 CSV→歌曲身分去重→songs+chart_entries)與
|
||||||
|
`ArtistImporter`(自資料庫依 song ID 讀回署名→拆解→正名
|
||||||
|
→去重登記→artists+song_artists),與 `CSVExporter` 同形
|
||||||
|
(建構子收 session、單一公開入口);命名取 import/export
|
||||||
|
對稱,不用 loader(有「載入記憶體」聯想,實為寫入持久
|
||||||
|
儲存)。歌手登記改於歌曲全數入庫後第二階段進行,兩塊
|
||||||
|
之間不再共享記憶體狀態;各 job 專用的純函數與規則表
|
||||||
|
(身分判定、拆解、正名)隨行入 class 作公開 staticmethod
|
||||||
|
/class 常數,「哪個函式屬哪個工作」由 class 歸屬直接
|
||||||
|
表達。flush 定為匯入工作的完工契約——entry method 返回
|
||||||
|
時自身寫入已可查詢,不再由呼叫者補 flush。實測重構前後
|
||||||
|
工作儲存 dump 與衍生報表逐位元組相同。
|
||||||
- **歌手型態刪去 mixed 值**:`ArtistType` 只留 solo/group。
|
- **歌手型態刪去 mixed 值**:`ArtistType` 只留 solo/group。
|
||||||
mixed 是先導研究「男/女/混合團體」單一欄位的殘留,
|
mixed 是先導研究「男/女/混合團體」單一欄位的殘留,
|
||||||
正式設計拆成 gender+type 後從未定義其指涉;署名一律
|
正式設計拆成 gender+type 後從未定義其指涉;署名一律
|
||||||
|
|||||||
@@ -26,18 +26,19 @@ database engine, given the frozen input file.
|
|||||||
|
|
||||||
A song is identified by its raw title together with its artist
|
A song is identified by its raw title together with its artist
|
||||||
credit, the credit canonicalized through
|
credit, the credit canonicalized through
|
||||||
``CANONICAL_ARTIST_CREDITS``; a credit listed there collapses onto
|
``SongImporter.CANONICAL_ARTIST_CREDITS``; a credit listed there
|
||||||
the same song as its canonical form, and the stored artist credit
|
collapses onto the same song as its canonical form, and the
|
||||||
is always the canonical form. Artist deduplication is by the
|
stored artist credit is always the canonical form. Artist
|
||||||
identity key resolved from the parsed artist name (see
|
deduplication is by the identity key resolved from the parsed
|
||||||
`resolve_artist_identity`): the case-folded name, or, when that
|
artist name (see `ArtistImporter.resolve_artist_identity`): the
|
||||||
case-folded name is listed in ``CANONICAL_ARTIST_NAMES``, the
|
case-folded name, or, when that case-folded name is listed in
|
||||||
case-folded canonical spelling, so letter-case variants and
|
``ArtistImporter.CANONICAL_ARTIST_NAMES``, the case-folded
|
||||||
alternate spellings mapped to the same canonical name all
|
canonical spelling, so letter-case variants and alternate
|
||||||
collapse onto a single artist row. The stored artist name is the
|
spellings mapped to the same canonical name all collapse onto a
|
||||||
first-seen spelling, except for the names listed in
|
single artist row. The stored artist name is the first-seen
|
||||||
``CANONICAL_ARTIST_NAMES``, which always store the canonical
|
spelling, except for the names listed in
|
||||||
spelling regardless of which variant is seen first.
|
``ArtistImporter.CANONICAL_ARTIST_NAMES``, which always store the
|
||||||
|
canonical spelling regardless of which variant is seen first.
|
||||||
|
|
||||||
On a successful build, two review CSV files, ``songs.csv`` and
|
On a successful build, two review CSV files, ``songs.csv`` and
|
||||||
``artists.csv``, are (re)written under the given output directory,
|
``artists.csv``, are (re)written under the given output directory,
|
||||||
@@ -78,88 +79,6 @@ ARTIST_FIELDS: dict[str, str] = {
|
|||||||
"country": "country",
|
"country": "country",
|
||||||
}
|
}
|
||||||
"""The artist CSV columns mapped to the Artist attributes."""
|
"""The artist CSV columns mapped to the Artist attributes."""
|
||||||
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 )")
|
|
||||||
"""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",
|
|
||||||
"amine": "Aminé",
|
|
||||||
"bomba estereo": "Bomba Estéreo",
|
|
||||||
"carolina gaitan": "Carolina Gaitán",
|
|
||||||
"casper magico": "Casper Mágico",
|
|
||||||
"eslabon armado": "Eslabón Armado",
|
|
||||||
"jhene aiko": "Jhené Aiko",
|
|
||||||
"neton vega": "Netón Vega",
|
|
||||||
"nio garcia": "Nio García",
|
|
||||||
"oscar maydon": "Óscar Maydon",
|
|
||||||
"silento": "Silentó",
|
|
||||||
"the marias": "The Marías",
|
|
||||||
"victoria monet": "Victoria Monét",
|
|
||||||
"dan": "Dan Smyers",
|
|
||||||
"shay": "Shay Mooney",
|
|
||||||
"cris mj": "Cris MJ",
|
|
||||||
"mariah the scientist": "Mariah the Scientist",
|
|
||||||
"surf mesa": "Surf Mesa",
|
|
||||||
}
|
|
||||||
"""The canonical artist spellings, keyed by the case-folded
|
|
||||||
identity."""
|
|
||||||
|
|
||||||
|
|
||||||
class BuildError(Exception):
|
class BuildError(Exception):
|
||||||
@@ -191,189 +110,319 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
def parse_artist_credit(credit: str) -> list[tuple[str, Role]]:
|
class SongImporter:
|
||||||
"""Parse a combined artist credit into artists and roles.
|
"""The song-import job: loads the chart CSV into songs and
|
||||||
|
chart entries."""
|
||||||
|
|
||||||
A credit listed in ``EXCEPTION_CREDITS`` is looked up verbatim,
|
CANONICAL_ARTIST_CREDITS: dict[str, str] = {
|
||||||
because its correct split is not derivable from the credit
|
"benny blanco, Halsey & Khalid": "Benny Blanco, Halsey"
|
||||||
text alone. Otherwise the credit first reduces to an
|
" & Khalid",
|
||||||
effective credit: a "<group>: <members>" prefix (split at the
|
}
|
||||||
first ": ") drops the group and keeps the members; failing
|
"""The canonical artist credit spellings, keyed by a variant
|
||||||
that, a "<group> (<members>)" suffix spanning the whole credit
|
credit string."""
|
||||||
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
|
def __init__(self, session: Session) -> None:
|
||||||
delimiters, other than the protected names, is over-split.
|
"""Initialize the importer.
|
||||||
|
|
||||||
:param credit: The combined artist credit string.
|
:param session: The database session.
|
||||||
:return: The (name, role) pairs in credit order, primary side
|
"""
|
||||||
first, with the role ``Role.PRIMARY`` or
|
self.__session: Session = session
|
||||||
``Role.FEATURED``.
|
self.__songs: dict[tuple[str, str], Song] = {}
|
||||||
"""
|
|
||||||
if credit in EXCEPTION_CREDITS:
|
def import_songs(self, path: Path) -> None:
|
||||||
return list(EXCEPTION_CREDITS[credit])
|
"""Load the chart CSV into songs and chart entries.
|
||||||
effective: str = credit
|
|
||||||
colon_match: re.Match[str] | None = COLON_PATTERN.search(
|
A song repeated across the rows is stored once, matched by
|
||||||
effective)
|
its identity key (see `song_identity`); every row yields
|
||||||
if colon_match is not None:
|
one chart entry. The stored title is the raw title; the
|
||||||
effective = effective[colon_match.end():]
|
stored artist credit is the canonical credit from the
|
||||||
else:
|
identity key. The songs take the IDs 1, 2, 3, ... in the
|
||||||
paren_match: re.Match[str] | None = \
|
first-occurrence row order. When the method returns, the
|
||||||
PAREN_MEMBERS_PATTERN.match(effective)
|
imported songs and chart entries are queryable in the
|
||||||
if paren_match is not None:
|
session.
|
||||||
effective = paren_match.group("members")
|
|
||||||
effective = DUET_WITH_PATTERN.sub(" with ", effective)
|
:param path: The chart CSV file with the columns year,
|
||||||
placeholders: dict[str, str] = {}
|
rank, title, and artist.
|
||||||
index: int
|
:return: None.
|
||||||
protected: str
|
:raises OSError: When the file cannot be read.
|
||||||
for index, protected in enumerate(PROTECTED_ARTIST_NAMES):
|
"""
|
||||||
if protected in effective:
|
with open(path, encoding="utf-8", newline="") as file:
|
||||||
placeholder: str = f"{index}"
|
row: dict[str, str]
|
||||||
placeholders[placeholder] = protected
|
for row in csv.DictReader(file):
|
||||||
effective = effective.replace(protected, placeholder)
|
key: tuple[str, str] = self.song_identity(
|
||||||
sides: list[str] = FEATURING_PATTERN.split(
|
row["title"], row["artist"])
|
||||||
effective, maxsplit=1)
|
if key not in self.__songs:
|
||||||
pairs: list[tuple[str, Role]] = []
|
title: str
|
||||||
role: Role
|
credit: str
|
||||||
side: str
|
title, credit = key
|
||||||
for side, role in zip(sides, (Role.PRIMARY, Role.FEATURED)):
|
song: Song = Song(
|
||||||
token: str
|
id=len(self.__songs) + 1, title=title,
|
||||||
for token in DELIMITER_PATTERN.split(side):
|
artist_credit=credit)
|
||||||
name: str = token.strip()
|
self.__session.add(song)
|
||||||
placeholder = ""
|
self.__songs[key] = song
|
||||||
original: str
|
self.__session.add(ChartEntry(
|
||||||
for placeholder, original in placeholders.items():
|
year=int(row["year"]), rank=int(row["rank"]),
|
||||||
name = name.replace(placeholder, original)
|
song=self.__songs[key]))
|
||||||
if name != "":
|
self.__session.flush()
|
||||||
pairs.append((name, role))
|
|
||||||
return pairs
|
@staticmethod
|
||||||
|
def song_identity(title: str, credit: str) -> tuple[str, str]:
|
||||||
|
"""Compute the identity key of a chart row.
|
||||||
|
|
||||||
|
The key pairs the raw title with the artist credit,
|
||||||
|
canonicalized through ``CANONICAL_ARTIST_CREDITS``; a
|
||||||
|
credit absent from the table maps to itself. Two chart
|
||||||
|
rows denote the same song iff their identity keys are
|
||||||
|
equal.
|
||||||
|
|
||||||
|
:param title: The song title as printed on the chart.
|
||||||
|
:param credit: The combined artist credit string.
|
||||||
|
:return: The identity key: the raw title paired with the
|
||||||
|
canonical artist credit.
|
||||||
|
"""
|
||||||
|
return title, SongImporter.CANONICAL_ARTIST_CREDITS.get(
|
||||||
|
credit, credit)
|
||||||
|
|
||||||
|
|
||||||
def song_identity(title: str, credit: str) -> tuple[str, str]:
|
class ArtistImporter:
|
||||||
"""Compute the identity key of a chart row.
|
"""The artist-import job: parses the stored songs' artist
|
||||||
|
credits into artists and song-artist credits."""
|
||||||
|
|
||||||
The key pairs the raw title with the artist credit,
|
FEATURING_PATTERN: re.Pattern[str] = re.compile(
|
||||||
canonicalized through ``CANONICAL_ARTIST_CREDITS``; a credit
|
r" featuring | feat\. ", re.IGNORECASE)
|
||||||
absent from the table maps to itself. Two chart rows denote
|
"""The pattern splitting the primary and featured sides."""
|
||||||
the same song iff their identity keys are equal.
|
DELIMITER_PATTERN: re.Pattern[str] = re.compile(
|
||||||
|
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_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",
|
||||||
|
"amine": "Aminé",
|
||||||
|
"bomba estereo": "Bomba Estéreo",
|
||||||
|
"carolina gaitan": "Carolina Gaitán",
|
||||||
|
"casper magico": "Casper Mágico",
|
||||||
|
"eslabon armado": "Eslabón Armado",
|
||||||
|
"jhene aiko": "Jhené Aiko",
|
||||||
|
"neton vega": "Netón Vega",
|
||||||
|
"nio garcia": "Nio García",
|
||||||
|
"oscar maydon": "Óscar Maydon",
|
||||||
|
"silento": "Silentó",
|
||||||
|
"the marias": "The Marías",
|
||||||
|
"victoria monet": "Victoria Monét",
|
||||||
|
"dan": "Dan Smyers",
|
||||||
|
"shay": "Shay Mooney",
|
||||||
|
"cris mj": "Cris MJ",
|
||||||
|
"mariah the scientist": "Mariah the Scientist",
|
||||||
|
"surf mesa": "Surf Mesa",
|
||||||
|
}
|
||||||
|
"""The canonical artist spellings, keyed by the case-folded
|
||||||
|
identity."""
|
||||||
|
|
||||||
:param title: The song title as printed on the chart.
|
def __init__(self, session: Session) -> None:
|
||||||
:param credit: The combined artist credit string.
|
"""Initialize the importer.
|
||||||
:return: The identity key: the raw title paired with the
|
|
||||||
canonical artist credit.
|
|
||||||
"""
|
|
||||||
return title, CANONICAL_ARTIST_CREDITS.get(credit, credit)
|
|
||||||
|
|
||||||
|
:param session: The database session.
|
||||||
|
"""
|
||||||
|
self.__session: Session = session
|
||||||
|
self.__artists: dict[str, Artist] = {}
|
||||||
|
|
||||||
def resolve_artist_identity(name: str) -> tuple[str, str]:
|
def import_artists(self) -> None:
|
||||||
"""Resolve the dedup key and the stored spelling of a name.
|
"""Parse the stored songs' credits into artists and
|
||||||
|
song-artist credits.
|
||||||
|
|
||||||
The name's case-folded form is looked up in
|
Reads the songs back from the database in ``Song.id`` order,
|
||||||
``CANONICAL_ARTIST_NAMES`` first; when it is listed there, the
|
including any songs pending in the same session, and for
|
||||||
dedup key is the canonical spelling case-folded and the stored
|
each song parses ``Song.artist_credit`` (see
|
||||||
spelling is the canonical spelling, so every variant of the
|
`parse_artist_credit`). An artist parsed out of a credit is
|
||||||
name, canonical or not, resolves to the same identity.
|
matched against the known artists by its identity key (see
|
||||||
Otherwise the dedup key is the name case-folded and the stored
|
`resolve_artist_identity`); a newly seen one takes the ID
|
||||||
spelling is the given name.
|
following the known artists, keyed by its identity key,
|
||||||
|
assigned in first-seen order across the songs, and its
|
||||||
|
stored name is the resolved stored spelling. An artist
|
||||||
|
duplicated within one song's credit, by its identity key,
|
||||||
|
is kept only at its first occurrence within that credit,
|
||||||
|
with a warning to the standard error. When the method
|
||||||
|
returns, the imported artists and credits are queryable in
|
||||||
|
the session.
|
||||||
|
|
||||||
:param name: An artist name, as parsed from a credit.
|
:return: None.
|
||||||
:return: A tuple of the dedup key and the stored spelling.
|
"""
|
||||||
"""
|
song: Song
|
||||||
folded: str = name.casefold()
|
for song in self.__session.scalars(
|
||||||
canonical: str | None = CANONICAL_ARTIST_NAMES.get(folded)
|
sa.select(Song).order_by(Song.id)):
|
||||||
if canonical is not None:
|
self.__import_song_artists(song)
|
||||||
return canonical.casefold(), canonical
|
self.__session.flush()
|
||||||
return folded, name
|
|
||||||
|
|
||||||
|
def __import_song_artists(self, song: Song) -> None:
|
||||||
|
"""Parse and store the artist credits of one song.
|
||||||
|
|
||||||
def create_song(session: Session, song_id: int, title: str,
|
:param song: The song with its stored artist credit.
|
||||||
credit: str, artists: dict[str, Artist]) -> Song:
|
:return: None.
|
||||||
"""Create a song with its parsed artist credits.
|
"""
|
||||||
|
seen: set[str] = set()
|
||||||
|
position: int = 0
|
||||||
|
name: str
|
||||||
|
role: Role
|
||||||
|
for name, role in self.parse_artist_credit(
|
||||||
|
song.artist_credit):
|
||||||
|
key: str
|
||||||
|
stored_name: str
|
||||||
|
key, stored_name = self.resolve_artist_identity(name)
|
||||||
|
if key in seen:
|
||||||
|
print(f"warning: {song.artist_credit}: duplicated"
|
||||||
|
f" artist \"{name}\"", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
if key not in self.__artists:
|
||||||
|
self.__artists[key] = Artist(
|
||||||
|
id=len(self.__artists) + 1, name=stored_name)
|
||||||
|
self.__session.add(SongArtist(
|
||||||
|
song=song, artist=self.__artists[key], role=role,
|
||||||
|
position=position))
|
||||||
|
position += 1
|
||||||
|
|
||||||
The song takes the given ID. An artist parsed out of the
|
@staticmethod
|
||||||
credit is matched against the known artists by its identity
|
def parse_artist_credit(credit: str) -> list[tuple[str, Role]]:
|
||||||
key (see `resolve_artist_identity`); a newly seen one takes
|
"""Parse a combined artist credit into artists and roles.
|
||||||
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.
|
A credit listed in ``EXCEPTION_CREDITS`` is looked up
|
||||||
:param song_id: The song ID to assign.
|
verbatim, because its correct split is not derivable from
|
||||||
:param title: The song title.
|
the credit text alone. Otherwise the credit first reduces
|
||||||
:param credit: The combined artist credit string.
|
to an effective credit: a "<group>: <members>" prefix
|
||||||
:param artists: The known artists by identity key, updated
|
(split at the first ": ") drops the group and keeps the
|
||||||
with the newly created ones as an observable side effect.
|
members; failing that, a "<group> (<members>)" suffix
|
||||||
:return: The created song, added to the session.
|
spanning the whole credit drops the group and keeps the
|
||||||
"""
|
members. The "Duet With" connector, case-insensitively,
|
||||||
song: Song = Song(id=song_id, title=title,
|
then normalizes to "with". The effective credit splits
|
||||||
artist_credit=credit)
|
into a primary side and a featured side on the word
|
||||||
session.add(song)
|
"featuring" or "feat.", case-insensitively; without them,
|
||||||
seen: set[str] = set()
|
every artist is primary. Each side splits into artist
|
||||||
position: int = 0
|
names on the delimiters ", ", " & ", " + ", " / "
|
||||||
name: str
|
(literally) and " and ", " x ", " with "
|
||||||
role: Role
|
(case-insensitively), except for the names listed in
|
||||||
for name, role in parse_artist_credit(credit):
|
``PROTECTED_ARTIST_NAMES``, which are never split even
|
||||||
key: str
|
though each contains a delimiter word or punctuation.
|
||||||
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(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
|
|
||||||
|
|
||||||
|
Known limitation: a compound act name that contains one of
|
||||||
|
the delimiters, other than the protected names, is
|
||||||
|
over-split.
|
||||||
|
|
||||||
def load_chart(session: Session, path: Path) -> None:
|
:param credit: The combined artist credit string.
|
||||||
"""Load the chart CSV into songs, chart entries, and credits.
|
:return: The (name, role) pairs in credit order, primary
|
||||||
|
side first, with the role ``Role.PRIMARY`` or
|
||||||
|
``Role.FEATURED``.
|
||||||
|
"""
|
||||||
|
if credit in ArtistImporter.EXCEPTION_CREDITS:
|
||||||
|
return list(ArtistImporter.EXCEPTION_CREDITS[credit])
|
||||||
|
effective: str = credit
|
||||||
|
colon_match: re.Match[str] | None = \
|
||||||
|
ArtistImporter.COLON_PATTERN.search(effective)
|
||||||
|
if colon_match is not None:
|
||||||
|
effective = effective[colon_match.end():]
|
||||||
|
else:
|
||||||
|
paren_match: re.Match[str] | None = \
|
||||||
|
ArtistImporter.PAREN_MEMBERS_PATTERN.match(
|
||||||
|
effective)
|
||||||
|
if paren_match is not None:
|
||||||
|
effective = paren_match.group("members")
|
||||||
|
effective = ArtistImporter.DUET_WITH_PATTERN.sub(
|
||||||
|
" with ", effective)
|
||||||
|
placeholders: dict[str, str] = {}
|
||||||
|
index: int
|
||||||
|
protected: str
|
||||||
|
for index, protected in enumerate(
|
||||||
|
ArtistImporter.PROTECTED_ARTIST_NAMES):
|
||||||
|
if protected in effective:
|
||||||
|
placeholder: str = f"{index}"
|
||||||
|
placeholders[placeholder] = protected
|
||||||
|
effective = effective.replace(protected, placeholder)
|
||||||
|
sides: list[str] = ArtistImporter.FEATURING_PATTERN.split(
|
||||||
|
effective, maxsplit=1)
|
||||||
|
pairs: list[tuple[str, Role]] = []
|
||||||
|
role: Role
|
||||||
|
side: str
|
||||||
|
for side, role in zip(sides, (Role.PRIMARY, Role.FEATURED)):
|
||||||
|
token: str
|
||||||
|
for token in ArtistImporter.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
|
||||||
|
|
||||||
A song repeated across the rows is stored once, matched by its
|
@staticmethod
|
||||||
identity key (see `song_identity`); every row yields one chart
|
def resolve_artist_identity(name: str) -> tuple[str, str]:
|
||||||
entry. The stored title is the raw title; the stored artist
|
"""Resolve the dedup key and the stored spelling of a name.
|
||||||
credit is the canonical credit from the identity key. The
|
|
||||||
songs and the artists take the IDs 1, 2, 3, ... in the
|
|
||||||
first-occurrence row order.
|
|
||||||
|
|
||||||
:param session: The database session.
|
The name's case-folded form is looked up in
|
||||||
:param path: The chart CSV file with the columns year, rank,
|
``CANONICAL_ARTIST_NAMES`` first; when it is listed there,
|
||||||
title, and artist.
|
the dedup key is the canonical spelling case-folded and the
|
||||||
:return: None.
|
stored spelling is the canonical spelling, so every variant
|
||||||
:raises OSError: When the file cannot be read.
|
of the name, canonical or not, resolves to the same
|
||||||
"""
|
identity. Otherwise the dedup key is the name case-folded
|
||||||
songs: dict[tuple[str, str], Song] = {}
|
and the stored spelling is the given name.
|
||||||
artists: dict[str, Artist] = {}
|
|
||||||
with open(path, encoding="utf-8", newline="") as file:
|
:param name: An artist name, as parsed from a credit.
|
||||||
row: dict[str, str]
|
:return: A tuple of the dedup key and the stored spelling.
|
||||||
for row in csv.DictReader(file):
|
"""
|
||||||
key: tuple[str, str] = song_identity(
|
folded: str = name.casefold()
|
||||||
row["title"], row["artist"])
|
canonical: str | None = \
|
||||||
if key not in songs:
|
ArtistImporter.CANONICAL_ARTIST_NAMES.get(folded)
|
||||||
title: str
|
if canonical is not None:
|
||||||
credit: str
|
return canonical.casefold(), canonical
|
||||||
title, credit = key
|
return folded, name
|
||||||
songs[key] = create_song(
|
|
||||||
session, len(songs) + 1, title, credit,
|
|
||||||
artists)
|
|
||||||
session.add(ChartEntry(year=int(row["year"]),
|
|
||||||
rank=int(row["rank"]),
|
|
||||||
song=songs[key]))
|
|
||||||
|
|
||||||
|
|
||||||
def load_lyrics(session: Session, directory: Path) -> None:
|
def load_lyrics(session: Session, directory: Path) -> None:
|
||||||
@@ -713,8 +762,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
counts: StoreCounts
|
counts: StoreCounts
|
||||||
try:
|
try:
|
||||||
reset_store(session)
|
reset_store(session)
|
||||||
load_chart(session, args.chart_csv)
|
SongImporter(session).import_songs(args.chart_csv)
|
||||||
session.flush()
|
ArtistImporter(session).import_artists()
|
||||||
if args.lyrics_dir is not None:
|
if args.lyrics_dir is not None:
|
||||||
if not args.lyrics_dir.is_dir():
|
if not args.lyrics_dir.is_dir():
|
||||||
raise BuildError(
|
raise BuildError(
|
||||||
|
|||||||
@@ -30,13 +30,14 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
|
|
||||||
def test_plain_solo(self) -> None:
|
def test_plain_solo(self) -> None:
|
||||||
"""Test a plain solo artist credit."""
|
"""Test a plain solo artist credit."""
|
||||||
self.assertEqual(build_db.parse_artist_credit("Adele"),
|
self.assertEqual(
|
||||||
[("Adele", Role.PRIMARY)])
|
build_db.ArtistImporter.parse_artist_credit("Adele"),
|
||||||
|
[("Adele", Role.PRIMARY)])
|
||||||
|
|
||||||
def test_featuring_with_and(self) -> None:
|
def test_featuring_with_and(self) -> None:
|
||||||
"""Test a featuring credit with an "and" delimiter."""
|
"""Test a featuring credit with an "and" delimiter."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"Drake featuring Wizkid and Kyla"),
|
"Drake featuring Wizkid and Kyla"),
|
||||||
[("Drake", Role.PRIMARY),
|
[("Drake", Role.PRIMARY),
|
||||||
("Wizkid", Role.FEATURED),
|
("Wizkid", Role.FEATURED),
|
||||||
@@ -45,7 +46,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_comma_and_ampersand(self) -> None:
|
def test_comma_and_ampersand(self) -> None:
|
||||||
"""Test a credit with comma and ampersand delimiters."""
|
"""Test a credit with comma and ampersand delimiters."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"Lady Gaga, Bradley Cooper & BloodPop"),
|
"Lady Gaga, Bradley Cooper & BloodPop"),
|
||||||
[("Lady Gaga", Role.PRIMARY),
|
[("Lady Gaga", Role.PRIMARY),
|
||||||
("Bradley Cooper", Role.PRIMARY),
|
("Bradley Cooper", Role.PRIMARY),
|
||||||
@@ -54,21 +55,23 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_x_delimiter(self) -> None:
|
def test_x_delimiter(self) -> None:
|
||||||
"""Test the "x" delimiter."""
|
"""Test the "x" delimiter."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit("KAROL G x Nicki Minaj"),
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"KAROL G x Nicki Minaj"),
|
||||||
[("KAROL G", Role.PRIMARY),
|
[("KAROL G", Role.PRIMARY),
|
||||||
("Nicki Minaj", Role.PRIMARY)])
|
("Nicki Minaj", Role.PRIMARY)])
|
||||||
|
|
||||||
def test_plus_delimiter(self) -> None:
|
def test_plus_delimiter(self) -> None:
|
||||||
"""Test the "+" delimiter."""
|
"""Test the "+" delimiter."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit("Marshmello + Halsey"),
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Marshmello + Halsey"),
|
||||||
[("Marshmello", Role.PRIMARY),
|
[("Marshmello", Role.PRIMARY),
|
||||||
("Halsey", Role.PRIMARY)])
|
("Halsey", Role.PRIMARY)])
|
||||||
|
|
||||||
def test_with_delimiter(self) -> None:
|
def test_with_delimiter(self) -> None:
|
||||||
"""Test the "with" delimiter."""
|
"""Test the "with" delimiter."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"Kane Brown with Lauren Alaina"),
|
"Kane Brown with Lauren Alaina"),
|
||||||
[("Kane Brown", Role.PRIMARY),
|
[("Kane Brown", Role.PRIMARY),
|
||||||
("Lauren Alaina", Role.PRIMARY)])
|
("Lauren Alaina", Role.PRIMARY)])
|
||||||
@@ -76,7 +79,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_feat_abbreviation(self) -> None:
|
def test_feat_abbreviation(self) -> None:
|
||||||
"""Test that "Feat." splits the featured side."""
|
"""Test that "Feat." splits the featured side."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"Ariana Grande Feat. Doja Cat"
|
"Ariana Grande Feat. Doja Cat"
|
||||||
" & Megan Thee Stallion"),
|
" & Megan Thee Stallion"),
|
||||||
[("Ariana Grande", Role.PRIMARY),
|
[("Ariana Grande", Role.PRIMARY),
|
||||||
@@ -86,7 +89,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_case_insensitive_featuring(self) -> None:
|
def test_case_insensitive_featuring(self) -> None:
|
||||||
"""Test that "Featuring" splits case-insensitively."""
|
"""Test that "Featuring" splits case-insensitively."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"24kGoldn Featuring iann dior"),
|
"24kGoldn Featuring iann dior"),
|
||||||
[("24kGoldn", Role.PRIMARY),
|
[("24kGoldn", Role.PRIMARY),
|
||||||
("iann dior", Role.FEATURED)])
|
("iann dior", Role.FEATURED)])
|
||||||
@@ -94,7 +97,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_colon_prefix_group(self) -> None:
|
def test_colon_prefix_group(self) -> None:
|
||||||
"""Test that a colon-prefixed group name is dropped."""
|
"""Test that a colon-prefixed group name is dropped."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"¥$: Ye & Ty Dolla $ign Featuring Rich The Kid"
|
"¥$: Ye & Ty Dolla $ign Featuring Rich The Kid"
|
||||||
" & Playboi Carti"),
|
" & Playboi Carti"),
|
||||||
[("Ye", Role.PRIMARY),
|
[("Ye", Role.PRIMARY),
|
||||||
@@ -105,7 +108,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_colon_prefix_with_ampersand_in_prefix(self) -> None:
|
def test_colon_prefix_with_ampersand_in_prefix(self) -> None:
|
||||||
"""Test a colon prefix that itself contains "&"."""
|
"""Test a colon prefix that itself contains "&"."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"Rumi & JINU: EJAE & Andrew Choi"),
|
"Rumi & JINU: EJAE & Andrew Choi"),
|
||||||
[("EJAE", Role.PRIMARY),
|
[("EJAE", Role.PRIMARY),
|
||||||
("Andrew Choi", Role.PRIMARY)])
|
("Andrew Choi", Role.PRIMARY)])
|
||||||
@@ -113,7 +116,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_colon_prefix_comma_list(self) -> None:
|
def test_colon_prefix_comma_list(self) -> None:
|
||||||
"""Test a colon-prefixed comma-separated member list."""
|
"""Test a colon-prefixed comma-separated member list."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"HUNTR/X: EJAE, Audrey Nuna & REI AMI"),
|
"HUNTR/X: EJAE, Audrey Nuna & REI AMI"),
|
||||||
[("EJAE", Role.PRIMARY),
|
[("EJAE", Role.PRIMARY),
|
||||||
("Audrey Nuna", Role.PRIMARY),
|
("Audrey Nuna", Role.PRIMARY),
|
||||||
@@ -122,7 +125,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_colon_prefix_five_members(self) -> None:
|
def test_colon_prefix_five_members(self) -> None:
|
||||||
"""Test a colon-prefixed five-member list."""
|
"""Test a colon-prefixed five-member list."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"Saja Boys: Andrew Choi, Neckwav, Danny Chung,"
|
"Saja Boys: Andrew Choi, Neckwav, Danny Chung,"
|
||||||
" Kevin Woo & samUIL Lee"),
|
" Kevin Woo & samUIL Lee"),
|
||||||
[("Andrew Choi", Role.PRIMARY),
|
[("Andrew Choi", Role.PRIMARY),
|
||||||
@@ -134,7 +137,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_colon_prefix_duo(self) -> None:
|
def test_colon_prefix_duo(self) -> None:
|
||||||
"""Test a colon-prefixed two-member list."""
|
"""Test a colon-prefixed two-member list."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"THE ANXIETY: WILLOW & Tyler Cole"),
|
"THE ANXIETY: WILLOW & Tyler Cole"),
|
||||||
[("WILLOW", Role.PRIMARY),
|
[("WILLOW", Role.PRIMARY),
|
||||||
("Tyler Cole", Role.PRIMARY)])
|
("Tyler Cole", Role.PRIMARY)])
|
||||||
@@ -143,7 +146,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
"""Test that a parenthesized member list replaces the
|
"""Test that a parenthesized member list replaces the
|
||||||
group name spanning the whole credit."""
|
group name spanning the whole credit."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"Silk Sonic (Bruno Mars & Anderson .Paak)"),
|
"Silk Sonic (Bruno Mars & Anderson .Paak)"),
|
||||||
[("Bruno Mars", Role.PRIMARY),
|
[("Bruno Mars", Role.PRIMARY),
|
||||||
("Anderson .Paak", Role.PRIMARY)])
|
("Anderson .Paak", Role.PRIMARY)])
|
||||||
@@ -152,12 +155,12 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
"""Test that "Duet With" is a co-billing connector like
|
"""Test that "Duet With" is a co-billing connector like
|
||||||
"with", dropping the word "Duet" entirely."""
|
"with", dropping the word "Duet" entirely."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"Blake Shelton Duet With Gwen Stefani"),
|
"Blake Shelton Duet With Gwen Stefani"),
|
||||||
[("Blake Shelton", Role.PRIMARY),
|
[("Blake Shelton", Role.PRIMARY),
|
||||||
("Gwen Stefani", Role.PRIMARY)])
|
("Gwen Stefani", Role.PRIMARY)])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"Keith Urban Duet With P!nk"),
|
"Keith Urban Duet With P!nk"),
|
||||||
[("Keith Urban", Role.PRIMARY),
|
[("Keith Urban", Role.PRIMARY),
|
||||||
("P!nk", Role.PRIMARY)])
|
("P!nk", Role.PRIMARY)])
|
||||||
@@ -165,12 +168,13 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_slash_delimiter(self) -> None:
|
def test_slash_delimiter(self) -> None:
|
||||||
"""Test the " / " co-billing delimiter."""
|
"""Test the " / " co-billing delimiter."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"Cole Swindell / Lainey Wilson"),
|
"Cole Swindell / Lainey Wilson"),
|
||||||
[("Cole Swindell", Role.PRIMARY),
|
[("Cole Swindell", Role.PRIMARY),
|
||||||
("Lainey Wilson", Role.PRIMARY)])
|
("Lainey Wilson", Role.PRIMARY)])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit("Zayn / Taylor Swift"),
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Zayn / Taylor Swift"),
|
||||||
[("Zayn", Role.PRIMARY),
|
[("Zayn", Role.PRIMARY),
|
||||||
("Taylor Swift", Role.PRIMARY)])
|
("Taylor Swift", Role.PRIMARY)])
|
||||||
|
|
||||||
@@ -178,7 +182,8 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
"""Test that "Lil Nas X" is guarded from the " x "
|
"""Test that "Lil Nas X" is guarded from the " x "
|
||||||
delimiter split."""
|
delimiter split."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit("Lil Nas X & Jack Harlow"),
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Lil Nas X & Jack Harlow"),
|
||||||
[("Lil Nas X", Role.PRIMARY),
|
[("Lil Nas X", Role.PRIMARY),
|
||||||
("Jack Harlow", Role.PRIMARY)])
|
("Jack Harlow", Role.PRIMARY)])
|
||||||
|
|
||||||
@@ -186,7 +191,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
"""Test that "Tyler, The Creator" is guarded from the
|
"""Test that "Tyler, The Creator" is guarded from the
|
||||||
comma delimiter split."""
|
comma delimiter split."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"Tyler, The Creator Featuring GloRilla, Sexyy Red"
|
"Tyler, The Creator Featuring GloRilla, Sexyy Red"
|
||||||
" & Lil Wayne"),
|
" & Lil Wayne"),
|
||||||
[("Tyler, The Creator", Role.PRIMARY),
|
[("Tyler, The Creator", Role.PRIMARY),
|
||||||
@@ -198,13 +203,14 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
"""Test that "Tones And I" is guarded from the " and "
|
"""Test that "Tones And I" is guarded from the " and "
|
||||||
delimiter split."""
|
delimiter split."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit("Tones And I"),
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Tones And I"),
|
||||||
[("Tones And I", Role.PRIMARY)])
|
[("Tones And I", Role.PRIMARY)])
|
||||||
|
|
||||||
def test_exception_spotemgottem(self) -> None:
|
def test_exception_spotemgottem(self) -> None:
|
||||||
"""Test the SpotemGottem exception-table credit."""
|
"""Test the SpotemGottem exception-table credit."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"SpotemGottem Featuring Pooh Shiesty Or DaBaby"),
|
"SpotemGottem Featuring Pooh Shiesty Or DaBaby"),
|
||||||
[("SpotemGottem", Role.PRIMARY),
|
[("SpotemGottem", Role.PRIMARY),
|
||||||
("Pooh Shiesty", Role.FEATURED),
|
("Pooh Shiesty", Role.FEATURED),
|
||||||
@@ -213,7 +219,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
def test_exception_the_scotts(self) -> None:
|
def test_exception_the_scotts(self) -> None:
|
||||||
"""Test the THE SCOTTS exception-table credit."""
|
"""Test the THE SCOTTS exception-table credit."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"THE SCOTTS, Travis Scott & Kid Cudi"),
|
"THE SCOTTS, Travis Scott & Kid Cudi"),
|
||||||
[("Travis Scott", Role.PRIMARY),
|
[("Travis Scott", Role.PRIMARY),
|
||||||
("Kid Cudi", Role.PRIMARY)])
|
("Kid Cudi", Role.PRIMARY)])
|
||||||
@@ -222,7 +228,7 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
"""Test the Drake Featuring The Throne exception-table
|
"""Test the Drake Featuring The Throne exception-table
|
||||||
credit."""
|
credit."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit(
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
"Drake Featuring The Throne"),
|
"Drake Featuring The Throne"),
|
||||||
[("Drake", Role.PRIMARY),
|
[("Drake", Role.PRIMARY),
|
||||||
("Jay Z", Role.FEATURED),
|
("Jay Z", Role.FEATURED),
|
||||||
@@ -232,7 +238,8 @@ class TestParseArtistCredit(unittest.TestCase):
|
|||||||
"""Test that the existing "+" delimiter split is
|
"""Test that the existing "+" delimiter split is
|
||||||
unaffected by the new rules."""
|
unaffected by the new rules."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
build_db.parse_artist_credit("Dan + Shay"),
|
build_db.ArtistImporter.parse_artist_credit(
|
||||||
|
"Dan + Shay"),
|
||||||
[("Dan", Role.PRIMARY), ("Shay", Role.PRIMARY)])
|
[("Dan", Role.PRIMARY), ("Shay", Role.PRIMARY)])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user